Files
buzz/desktop/tests/e2e/voice-settings.spec.ts
c104eecfb3 feat(desktop): import local Pocket voices (#3259)
## Context

Pocket TTS currently offers bundled reference voices. People also need a
local, private way to add a voice without sending audio to a cloud
service.

## Summary

Add a Pocket voice import flow to Voice settings. Buzz opens the native
file picker, decodes common audio formats in the reusable `buzz-voice`
crate, canonicalizes the selected audio, stores it under a
content-derived identity in app data, selects it, and lets the user
delete it later.

## Changes

- Accept WAV, M4A, MP3, FLAC, OGG, and AIFF files between 2 and 30
seconds, including multichannel sources.
- Decode and downmix accepted audio to canonical mono 32 kHz PCM16 WAV
before hashing and storage.
- Store imported voices behind stable `pocket:imported:<sha256>`
identities and content-addressed files.
- Keep absolute file paths inside the native process and expose only
voice metadata to React.
- Include imported voices in Pocket preview and live huddle playback.
- Add Add voice and delete controls while preserving the bundled Pocket
voice catalog.
- Fall back to Mary when the selected imported voice is deleted.
- Keep durable import, selection, and deletion successful when a live
TTS worker acknowledgement is delayed.
- Preserve bundled voices when optional import metadata is unreadable
and keep failed deletion retryable.

## Related issue

None found.

## Testing

Production decoding was exercised with WAV, M4A with AAC, MP3, FLAC, OGG
Vorbis, and AIFF fixtures. Each format canonicalized to mono 32 kHz
PCM16 WAV. Manual validation in the combined daily-driver build covered
native-picker import, Preview, live-huddle playback, deletion, and Mary
fallback.

## Screenshots

The Voice settings card preserves the bundled Pocket catalog and adds
the local Add voice action.

![Pocket TTS voice
import](https://raw.githubusercontent.com/block/buzz/c03ba29060ca544c5ac3394c212f376651b386a3/pr-3259--pocket-voices.png)

## Reviewer-reproducible examples

Create common-format fixtures and run them through the production
importer:

```bash
. ./bin/activate-hermit
fixtures="$(mktemp -d)"
ffmpeg -hide_banner -loglevel error -f lavfi -i "sine=frequency=220:duration=3" -ac 2 -ar 44100 "$fixtures/voice.wav"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a aac "$fixtures/voice.m4a"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.mp3"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" "$fixtures/voice.flac"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a libvorbis "$fixtures/voice.ogg"
ffmpeg -hide_banner -loglevel error -i "$fixtures/voice.wav" -c:a pcm_s16be "$fixtures/voice.aiff"
BUZZ_VOICE_IMPORT_TEST_DIR="$fixtures" \
  cargo test -p buzz-voice imports_common_audio_format_fixtures -- --ignored --nocapture
```

Exercise import persistence, synthesis, deletion, and bundled-voice
fallback with an installed Pocket model:

```bash
BUZZ_POCKET_MODEL_DIR=/path/to/pocket-model-bundle \
  cargo test -p buzz-voice --test pocket_import_audio \
  objective_import_synthesis_delete_and_mary_fallback \
  -- --ignored --nocapture
```

Exercise the native-picker boundary, selection, preview dispatch,
deletion, cancellation, and invalid-file states:

```bash
cd desktop
pnpm build:e2e
pnpm exec playwright test tests/e2e/voice-settings.spec.ts --project=smoke
```

---------

Signed-off-by: John Tennant <jtennant@block.xyz>
Signed-off-by: John Tennant <johnmatthewtennant@gmail.com>
Signed-off-by: John Tennant <jtennant@squareup.com>
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: John Tennant <jtennant@block.xyz>
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
2026-07-31 11:15:09 -04:00

213 lines
7.2 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { waitForAnimations } from "../helpers/animations";
import { installMockBridge } from "../helpers/bridge";
import { openSettings } from "../helpers/settings";
const SCREENSHOT_PATH = "test-results/voice-settings/pocket-voices.png";
test.describe("Pocket voice settings", () => {
test.use({ viewport: { width: 1100, height: 760 } });
test("selects and retains a bundled voice while text to speech is off", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "voice");
const card = page.getByTestId("settings-voice");
await expect(card).toBeVisible();
await expect(
page.getByText("Agent text to speech", { exact: true }),
).toBeVisible();
await expect(
page.getByText("Pocket TTS voice", { exact: true }),
).toBeVisible();
await expect(card).not.toContainText("April INT8");
await page.getByTestId("pocket-voice-selector").click();
await expect(page.getByRole("menuitemradio")).toHaveCount(12);
await page.getByRole("menuitemradio", { name: "Eve" }).click();
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Eve",
);
await expect(
page.getByRole("button", { name: "Pocket TTS voice: Eve" }),
).toBeVisible();
await page.getByTestId("agent-text-to-speech-toggle").click();
await expect(
page.getByTestId("agent-text-to-speech-toggle"),
).toHaveAttribute("aria-checked", "false");
await expect(page.getByTestId("pocket-voice-controls")).toHaveAttribute(
"aria-disabled",
"true",
);
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Eve",
);
const savedCommands = await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter((entry) =>
["set_pocket_voice", "set_tts_enabled"].includes(entry.command),
)
.map((entry) => ({ command: entry.command, payload: entry.payload })),
);
expect(savedCommands).toEqual([
{
command: "set_pocket_voice",
payload: { voiceKey: "pocket:eve" },
},
{
command: "set_tts_enabled",
payload: { enabled: false },
},
]);
});
test("captures the complete VCTK preset settings surface", async ({
page,
}) => {
await installMockBridge(page, {
ttsSettings: {
version: 1,
agentTextToSpeech: true,
voicePreferences: ["pocket:eve"],
},
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "voice");
const card = page.getByTestId("settings-voice");
await expect(card).toBeVisible();
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Eve",
);
await page.getByTestId("pocket-voice-selector").click();
await expect(page.getByRole("menuitemradio")).toHaveCount(12);
const menu = page.getByRole("menu");
await expect(menu).toBeVisible();
await waitForAnimations(page);
const cardBox = await card.boundingBox();
const menuBox = await menu.boundingBox();
const viewport = page.viewportSize();
if (!cardBox || !menuBox || !viewport) {
throw new Error("Voice settings screenshot bounds are unavailable");
}
const x = Math.max(0, Math.min(cardBox.x, menuBox.x) - 16);
const y = Math.max(0, Math.min(cardBox.y, menuBox.y) - 16);
const right = Math.min(
viewport.width,
Math.max(cardBox.x + cardBox.width, menuBox.x + menuBox.width) + 16,
);
const bottom = Math.min(
viewport.height,
Math.max(cardBox.y + cardBox.height, menuBox.y + menuBox.height) + 16,
);
await page.screenshot({
path: SCREENSHOT_PATH,
clip: {
x: Math.floor(x),
y: Math.floor(y),
width: Math.ceil(right - x),
height: Math.ceil(bottom - y),
},
});
});
test("imports, selects, and safely deletes a local voice", async ({
page,
}) => {
await installMockBridge(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "voice");
await page.getByTestId("pocket-voice-import").click();
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"My voice",
);
await expect(page.getByTestId("pocket-voice-delete")).toBeVisible();
await page.getByRole("button", { name: "Preview" }).click();
await page.getByTestId("pocket-voice-delete").click();
await expect(page.getByText("Delete imported voice?")).toBeVisible();
await page.getByTestId("confirm-pocket-voice-delete").click();
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Mary",
);
await expect(page.getByTestId("pocket-voice-delete")).toBeHidden();
await page.getByRole("button", { name: "Preview" }).click();
const mutations = await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? [])
.filter((entry) =>
[
"import_pocket_voice",
"preview_pocket_voice",
"delete_pocket_voice",
].includes(entry.command),
)
.map((entry) => ({ command: entry.command, payload: entry.payload })),
);
expect(mutations).toEqual([
{ command: "import_pocket_voice", payload: {} },
{
command: "preview_pocket_voice",
payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` },
},
{
command: "delete_pocket_voice",
payload: { voiceKey: `pocket:imported:${"1".repeat(64)}` },
},
{
command: "preview_pocket_voice",
payload: { voiceKey: "pocket:mary" },
},
]);
});
test("keeps the selected voice unchanged when the native picker is cancelled", async ({
page,
}) => {
await installMockBridge(page, { pocketVoiceImportResult: "cancel" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "voice");
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Mary",
);
await page.getByTestId("pocket-voice-import").click();
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Mary",
);
await expect(page.getByTestId("pocket-voice-delete")).toBeHidden();
await expect(page.getByTestId("voice-settings-error")).toBeHidden();
const audioCommands = await page.evaluate(() =>
(window.__BUZZ_E2E_COMMAND_LOG__ ?? []).filter((entry) =>
["preview_pocket_voice", "delete_pocket_voice"].includes(entry.command),
),
);
expect(audioCommands).toEqual([]);
});
test("surfaces invalid or unsupported WAV errors without changing selection", async ({
page,
}) => {
await installMockBridge(page, { pocketVoiceImportResult: "invalid" });
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "voice");
await page.getByTestId("pocket-voice-import").click();
await expect(page.getByTestId("voice-settings-error")).toContainText(
"Voice WAV must contain PCM or 32-bit float audio",
);
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Mary",
);
await expect(page.getByTestId("pocket-voice-delete")).toBeHidden();
});
});