Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 12 additions & 11 deletions src/plugins/AIComponents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,17 +588,17 @@ type HeardTextSubscribe = (listener: () => void) => () => void;
const useHeardText: (params: UseHeardTextParams) => UseHeardTextResult;
```

`heard(now)` returns how many characters of the text the user has heard by `now` (a `performance.now()` timestamp) and whether the speech for it has finished. `chars` counts Unicode code points (`Array.from(text).length`), not UTF-16 code units. Return `undefined` when the text is not paced, and `{ chars: 0, done: false }` for a spoken text whose audio has not started yet.
`heard(now)` returns how many characters of the text the user has heard by `now` (a `performance.now()` timestamp) and whether the speech for it has finished. `chars` counts Unicode code points (`Array.from(text).length`), not UTF-16 code units; `Infinity` means the whole text. Return `undefined` only for a text that is not spoken at all. A text that will be spoken but whose audio has not started, or that the voice source does not know yet, must return `{ chars: 0, done: false }`: while `heard` returns `undefined` the full text is shown, and the reveal never moves back past what has been shown. `heard` is also called during render, so keep it free of side effects.

#### Props

| Name | Type | Required | Description |
| ------------ | -------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ |
| `text` | `string` | yes | The full text to reveal. |
| `heard` | `HeardTextFunction` | yes | The speech progress of `text`. |
| `subscribe` | `HeardTextSubscribe` | no | Change notifications from the speech progress source; each one makes the hook check `heard` again. |
| `generating` | `boolean` | no | `true` while the text is still being written. The full text is revealed once `heard` reports `done` and this is falsy. |
| `live` | `boolean` | no | Defaults to `true`. `false` reveals the full text at once, for example for a message loaded from history. Read at mount. |
| Name | Type | Required | Description |
| ------------ | -------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------- |
| `text` | `string` | yes | The full text to reveal. |
| `heard` | `HeardTextFunction` | yes | The speech progress of `text`. |
| `subscribe` | `HeardTextSubscribe` | no | Change notifications from the speech progress source; each one makes the hook check `heard` again. See below. |
| `generating` | `boolean` | no | `true` while the text is still being written. The full text is revealed once `heard` reports `done` and this is falsy. |
| `live` | `boolean` | no | Defaults to `true`. `false` reveals the full text at once, for example for a message loaded from history. See below. |

#### Returns

Expand All @@ -610,10 +610,11 @@ const useHeardText: (params: UseHeardTextParams) => UseHeardTextResult;

Behaviour:

- It reveals the first `floor(chars)` code points of `text` and never moves backwards. Emoji and other astral characters are never split.
- While the text is paced and its speech is not done, it samples `heard` on every animation frame, and stops when the speech is done or the component unmounts. `subscribe` notifications and changes of `text` or `generating` make it check again.
- It reveals the first `floor(chars)` code points of `text` and never moves backwards while `text` grows or is truncated. Emoji and other astral characters are never split.
- When `text` is replaced by a different text (the next reply rendered by the same component), the reveal starts over: the length shown and `skip()` are reset, and `live` is read again. `live` is otherwise read only at mount.
- While the text is paced and its speech is not done, it samples `heard` on every animation frame, and stops when the speech is done or the component unmounts. `subscribe` notifications and changes of `text` or `generating` make it check again. Without `subscribe`, nothing else does: pass it when `heard` can change after the speech is done, or start returning a value after returning `undefined`.
- If `heard` returns `undefined` at first and a value later, the reveal continues from the length already shown.
- `heard` and `subscribe` are read through refs: passing new functions on every render (inline arrow functions) does not restart the pacing. `subscribe` is called once and unsubscribed on unmount.
- `heard` and `subscribe` are read through refs: passing new functions on every render (inline arrow functions) does not restart the pacing. `subscribe` is called once and unsubscribed on unmount; to switch to another speech source, remount the component (for example with a `key`).

#### Example

Expand Down
86 changes: 85 additions & 1 deletion src/plugins/AIComponents/__tests__/useHeardText.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,31 @@ describe('useHeardText', () => {
expect(result.current.heardText).toBe(TEXT.slice(0, 4));
});

it('treats non-finite and negative counts as nothing heard', () => {
it('treats NaN, negative and non-numeric counts as nothing heard', () => {
let progress: HeardTextProgress = { chars: Number.NaN, done: false };
const { result } = renderHeardText({ heard: () => progress, text: TEXT });
expect(result.current.heardText).toBe('');

progress = { chars: -3, done: false };
advance(FRAME_MS);
expect(result.current.heardText).toBe('');

progress = { chars: Number.NEGATIVE_INFINITY, done: false };
advance(FRAME_MS);
expect(result.current.heardText).toBe('');

// a JS caller returning no count at all
progress = { done: false } as unknown as HeardTextProgress;
advance(FRAME_MS);
expect(result.current.heardText).toBe('');
});

it('treats an infinite count as the whole text heard', () => {
const { result } = renderHeardText({
heard: () => ({ chars: Number.POSITIVE_INFINITY, done: false }),
text: TEXT,
});
expect(result.current).toMatchObject({ heardText: TEXT, isPaced: true });
});

it('never moves the reveal backwards', () => {
Expand Down Expand Up @@ -290,6 +307,73 @@ describe('useHeardText', () => {
expect(result.current.heardText).toBe(TEXT);
});

describe('when the text is replaced by a different text', () => {
const NEXT_TEXT = 'Sure, here is the forecast for tomorrow.';

it('starts a new reveal instead of carrying over the length already shown', () => {
let progress: HeardTextProgress = { chars: 100, done: false };
const heard: HeardTextFunction = () => progress;
const { rerender, result } = renderHeardText({ heard, text: TEXT });
expect(result.current.heardText).toBe(TEXT);

// the next reply, whose audio has not started yet
progress = { chars: 0, done: false };
rerender({ heard, text: NEXT_TEXT });
expect(result.current).toMatchObject({ heardText: '', isPaced: true });

progress = { chars: 4, done: false };
advance(FRAME_MS);
expect(result.current.heardText).toBe('Sure');
});

it('starts a new reveal after the previous text was shown unpaced', () => {
let progress: HeardTextProgress | undefined = undefined;
const heard: HeardTextFunction = () => progress;
const { rerender, result } = renderHeardText({ heard, text: TEXT });
expect(result.current).toMatchObject({ heardText: TEXT, isPaced: false });

progress = { chars: 2, done: false };
rerender({ heard, text: NEXT_TEXT });
expect(result.current).toMatchObject({ heardText: 'Su', isPaced: true });
});

it('clears a previous skip', () => {
const heard: HeardTextFunction = () => ({ chars: 3, done: false });
const { rerender, result } = renderHeardText({ heard, text: TEXT });
act(() => result.current.skip());
expect(result.current.heardText).toBe(TEXT);

rerender({ heard, text: NEXT_TEXT });
expect(result.current.heardText).toBe('Sur');
});

it('reads live again for the new text', () => {
const heard: HeardTextFunction = () => ({ chars: 3, done: false });
const { rerender, result } = renderHeardText({ heard, live: false, text: TEXT });
expect(result.current.heardText).toBe(TEXT);

rerender({ heard, live: true, text: NEXT_TEXT });
expect(result.current.heardText).toBe('Sur');
});

it('keeps the reveal when the text is truncated, and does not reuse the cut length', () => {
let progress: HeardTextProgress = { chars: 12, done: false };
const heard: HeardTextFunction = () => progress;
const { rerender, result } = renderHeardText({ heard, text: TEXT });
expect(result.current.heardText).toBe(TEXT.slice(0, 12));

// e.g. the reply was cut short when the user stopped it
rerender({ heard, text: TEXT.slice(0, 8) });
expect(result.current.heardText).toBe(TEXT.slice(0, 8));

// growing again from the truncated text continues from 8, not from 12
progress = { chars: 3, done: false };
rerender({ heard, text: `${TEXT.slice(0, 8)} again and again` });
advance(FRAME_MS);
expect(result.current.heardText).toBe(TEXT.slice(0, 8));
});
});

describe('with emoji and other astral characters', () => {
// waving hand + skin tone modifier, mathematical script X, grinning face, the
// US flag (two regional indicators) and a BMP character: all but the last are
Expand Down
126 changes: 95 additions & 31 deletions src/plugins/AIComponents/hooks/useHeardText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,14 @@ export type HeardTextProgress = {

/**
* Returns how much of the text the user has heard by `now` (a `performance.now()`
* timestamp). Return `undefined` when the text is not paced (not spoken, or not known
* to the voice source yet); return `{ chars: 0, done: false }` for a spoken text whose
* audio has not started yet.
* timestamp).
*
* Return `undefined` only for a text that is not spoken at all: the full text is then
* shown, and the reveal never moves back past what has been shown. A text that will be
* spoken but whose audio has not started, or that the voice source does not know yet,
* must return `{ chars: 0, done: false }`, or it is shown in full before it is heard.
*
* Also called during render, so keep it free of side effects.
*/
export type HeardTextFunction = (now: number) => HeardTextProgress | undefined;

Expand All @@ -39,10 +44,16 @@ export type UseHeardTextParams = {
generating?: boolean;
/**
* Defaults to `true`. `false` reveals the full text at once, for example for a message
* loaded from history. Read once, when the hook mounts.
* loaded from history. Read when the hook mounts and when `text` is replaced by a
* different text, not on every change.
*/
live?: boolean;
/** Optional change notifications from the speech progress source. */
/**
* Optional change notifications from the speech progress source. Without it, `heard`
* is only checked again while the text is paced and its speech is not done, and when
* `text` or `generating` change. Subscribed once; to switch to another source, remount
* the component (for example with a `key`).
*/
subscribe?: HeardTextSubscribe;
};

Expand All @@ -56,13 +67,22 @@ export type UseHeardTextResult = {
};

type PacingState = {
/** Number of code points revealed. Never decreases. */
/** Number of code points revealed. Never decreases while the text continues. */
count: number;
isPaced: boolean;
/** No more ticking is needed until the inputs change or the source notifies. */
settled: boolean;
};

/** The reveal of one text. Starts over when the text is replaced by a different text. */
type RevealState = PacingState & {
/** `live` as read when the reveal started. */
live: boolean;
skipped: boolean;
/** The text `count` refers to. */
text: string;
};

const now = () =>
typeof performance !== 'undefined' && typeof performance.now === 'function'
? performance.now()
Expand Down Expand Up @@ -121,9 +141,10 @@ const sample = ({
return { count: Math.max(previousCount, textLength), isPaced: true, settled: true };
}

const heardChars = Number.isFinite(progress.chars)
? Math.max(0, Math.floor(progress.chars))
: 0;
// `+Infinity` means the whole text has been heard; NaN, negative and non-numeric
// counts mean nothing has
const flooredChars = Math.floor(progress.chars);
const heardChars = flooredChars > 0 ? flooredChars : 0;

return {
count: Math.max(previousCount, Math.min(heardChars, textLength)),
Expand All @@ -132,6 +153,39 @@ const sample = ({
};
};

const startReveal = ({
generating,
heard,
live,
text,
textLength,
}: {
generating: boolean;
heard: HeardTextFunction;
live: boolean;
text: string;
textLength: number;
}): RevealState => ({
...sample({ generating, heard, live, previousCount: 0, skipped: false, textLength }),
live,
skipped: false,
text,
});

/**
* Whether `text` continues the reveal of `previous.text`: it extends or truncates the part
* already shown. Anything else (the next reply rendered by the same component) is a new
* text, whose reveal must not start from the length shown for the previous one.
*/
const continuesReveal = (previous: RevealState, text: string) => {
const offsets = getCodePointOffsets(previous.text);
const shown = previous.text.slice(
0,
offsets[Math.min(previous.count, offsets.length - 1)],
);
return text.startsWith(shown) || shown.startsWith(text);
};

const noop = () => undefined;

/**
Expand All @@ -141,20 +195,24 @@ const noop = () => undefined;
* backend and has no dependency on the chat SDK.
*
* - Reveals the first `floor(chars)` Unicode code points of `text` (emoji and other
* astral characters are never split) and never moves backwards.
* astral characters are never split) and never moves backwards while `text` grows or
* is truncated. When `text` is replaced by a different text (the next reply rendered
* by the same component), the reveal starts over: the length shown, `skip()` and
* `live` are reset.
* - While the text is paced and its speech is not done, samples `heard` on every
* animation frame (a 16 ms timeout without `requestAnimationFrame`). It stops ticking
* when the speech is done or the component unmounts; `subscribe` notifications, and
* changes of `text` or `generating`, make it check again.
* - Reveals the full text once `heard` reports `done` and `generating` is falsy, when
* `live` is `false` at mount, and after `skip()`.
* `live` is `false`, and after `skip()`.
* - When `heard` returns `undefined`, `isPaced` is `false` and `heardText` is the full
* text. If it becomes paced later, the reveal continues from the length already shown.
*
* `heard` and `subscribe` are read through refs, so passing new function identities on
* every render (inline arrow functions) does not restart the pacing. `subscribe` is
* called once on mount (and again only if it goes from absent to present) and its
* unsubscribe function runs on unmount.
* unsubscribe function runs on unmount; to switch to another speech source, remount the
* component. `heard` is also called during render, so keep it free of side effects.
*
* @example
* ```tsx
Expand Down Expand Up @@ -186,21 +244,23 @@ export const useHeardText = ({
const subscribeRef = useRef(subscribe);
subscribeRef.current = subscribe;

const [isLive] = useState(live);
const [skipped, setSkipped] = useState(false);
const [pacingState, setPacingState] = useState<PacingState>(() =>
sample({
generating,
heard,
live: isLive,
previousCount: 0,
skipped,
textLength,
}),
const [revealState, setRevealState] = useState<RevealState>(() =>
startReveal({ generating, heard, live, text, textLength }),
);
const pacingStateRef = useRef(pacingState);
let reveal = revealState;
if (revealState.text !== text) {
// Updated during render so that the new text never renders with the previous reveal.
reveal = continuesReveal(revealState, text)
? { ...revealState, count: Math.min(revealState.count, textLength), text }
: startReveal({ generating, heard, live, text, textLength });
setRevealState(reveal);
}
const revealRef = useRef(reveal);
revealRef.current = reveal;
const wakeRef = useRef<() => void>(noop);

const { live: isLive, skipped } = reveal;

useEffect(() => {
let cancelFrame: (() => void) | undefined;
let disposed = false;
Expand All @@ -209,7 +269,7 @@ export const useHeardText = ({
cancelFrame = undefined;
if (disposed) return;

const previous = pacingStateRef.current;
const previous = revealRef.current;
const next = sample({
generating,
heard: heardRef.current,
Expand All @@ -224,8 +284,9 @@ export const useHeardText = ({
next.isPaced !== previous.isPaced ||
next.settled !== previous.settled
) {
pacingStateRef.current = next;
setPacingState(next);
const updated = { ...previous, ...next };
revealRef.current = updated;
setRevealState(updated);
}

if (!next.settled) cancelFrame = scheduleFrame(tick);
Expand All @@ -249,13 +310,16 @@ export const useHeardText = ({
return subscribeRef.current?.(() => wakeRef.current());
}, [hasSubscribe]);

const skip = useCallback(() => setSkipped(true), []);
const skip = useCallback(
() => setRevealState((previous) => ({ ...previous, skipped: true })),
[],
);

return {
heardText: pacingState.isPaced
? text.slice(0, codePointOffsets[Math.min(pacingState.count, textLength)])
heardText: reveal.isPaced
? text.slice(0, codePointOffsets[Math.min(reveal.count, textLength)])
: text,
isPaced: pacingState.isPaced,
isPaced: reveal.isPaced,
skip,
};
};
Loading