mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
## 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.  ## 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>
213 lines
7.2 KiB
TypeScript
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();
|
|
});
|
|
});
|