From c104eecfb38620de2c35c7e20a716f8658b5a6b1 Mon Sep 17 00:00:00 2001
From: John Matthew Tennant
Date: Fri, 31 Jul 2026 11:15:09 -0400
Subject: [PATCH] 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:`
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
Signed-off-by: John Tennant
Signed-off-by: John Tennant
Signed-off-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: John Tennant
Co-authored-by: npub1qyvc0c5kl4gqv2fd97fsk46tu378sqgy35vc83rvgfwne90sel7s0ed67d <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: npub12gtutshhh76rx0jx697f32f9tffd4hhp3hx58fp4x6u4uemkm7sqf8f757 <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
---
Cargo.lock | 191 +++++
crates/buzz-voice/Cargo.toml | 7 +
crates/buzz-voice/src/imported.rs | 730 ++++++++++++++++++
crates/buzz-voice/src/lib.rs | 1 +
.../buzz-voice/tests/pocket_import_audio.rs | 133 ++++
desktop/src-tauri/Cargo.lock | 15 +
desktop/src-tauri/src/huddle/mod.rs | 1 +
desktop/src-tauri/src/huddle/pipeline.rs | 63 +-
desktop/src-tauri/src/huddle/tts_settings.rs | 212 ++++-
.../src-tauri/src/huddle/tts_voice_import.rs | 59 ++
.../src/huddle/tts_voice_transition.rs | 11 +-
desktop/src-tauri/src/lib.rs | 2 +
.../settings/ui/VoiceSettingsCard.tsx | 120 ++-
desktop/src/testing/e2eBridge.ts | 296 ++++---
desktop/tests/e2e/voice-settings.spec.ts | 93 +++
desktop/tests/helpers/bridge.ts | 2 +
16 files changed, 1778 insertions(+), 158 deletions(-)
create mode 100644 crates/buzz-voice/src/imported.rs
create mode 100644 crates/buzz-voice/tests/pocket_import_audio.rs
create mode 100644 desktop/src-tauri/src/huddle/tts_voice_import.rs
diff --git a/Cargo.lock b/Cargo.lock
index 5d9a2d764..9a3f91671 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -410,6 +410,16 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+[[package]]
+name = "atomic-write-file"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84790c55b5704b0d35130bf16a4ce22a8e70eb0ea773522557524d9a4852663d"
+dependencies = [
+ "nix 0.30.1",
+ "rand 0.9.4",
+]
+
[[package]]
name = "attohttpc"
version = "0.30.1"
@@ -1295,13 +1305,18 @@ dependencies = [
name = "buzz-voice"
version = "0.1.0"
dependencies = [
+ "atomic-write-file",
+ "hex",
"ort",
"ort-sys",
"rand 0.10.1",
"sentencepiece-model",
"serde",
"serde_json",
+ "sha2 0.11.0",
"sherpa-onnx",
+ "symphonia",
+ "tempfile",
"tokenizers",
]
@@ -2762,6 +2777,12 @@ dependencies = [
"smallvec",
]
+[[package]]
+name = "extended"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
+
[[package]]
name = "fancy-regex"
version = "0.11.0"
@@ -5580,6 +5601,18 @@ dependencies = [
"memoffset",
]
+[[package]]
+name = "nix"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
+dependencies = [
+ "bitflags 2.13.0",
+ "cfg-if 1.0.4",
+ "cfg_aliases",
+ "libc",
+]
+
[[package]]
name = "nix"
version = "0.31.3"
@@ -9016,6 +9049,164 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a"
+[[package]]
+name = "symphonia"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
+dependencies = [
+ "lazy_static",
+ "symphonia-bundle-flac",
+ "symphonia-bundle-mp3",
+ "symphonia-codec-aac",
+ "symphonia-codec-alac",
+ "symphonia-codec-pcm",
+ "symphonia-codec-vorbis",
+ "symphonia-core",
+ "symphonia-format-isomp4",
+ "symphonia-format-ogg",
+ "symphonia-format-riff",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-bundle-flac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-bundle-mp3"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-codec-aac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
+dependencies = [
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-alac"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8413fa754942ac16a73634c9dfd1500ed5c61430956b33728567f667fdd393ab"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-pcm"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
+dependencies = [
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-codec-vorbis"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-core"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
+dependencies = [
+ "arrayvec",
+ "bitflags 1.3.2",
+ "bytemuck",
+ "lazy_static",
+ "log",
+]
+
+[[package]]
+name = "symphonia-format-isomp4"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "243739585d11f81daf8dac8d9f3d18cc7898f6c09a259675fc364b382c30e0a5"
+dependencies = [
+ "encoding_rs",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-format-ogg"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
+dependencies = [
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+ "symphonia-utils-xiph",
+]
+
+[[package]]
+name = "symphonia-format-riff"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
+dependencies = [
+ "extended",
+ "log",
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
+[[package]]
+name = "symphonia-metadata"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
+dependencies = [
+ "encoding_rs",
+ "lazy_static",
+ "log",
+ "symphonia-core",
+]
+
+[[package]]
+name = "symphonia-utils-xiph"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
+dependencies = [
+ "symphonia-core",
+ "symphonia-metadata",
+]
+
[[package]]
name = "syn"
version = "1.0.109"
diff --git a/crates/buzz-voice/Cargo.toml b/crates/buzz-voice/Cargo.toml
index 3574c3291..beff5b4a5 100644
--- a/crates/buzz-voice/Cargo.toml
+++ b/crates/buzz-voice/Cargo.toml
@@ -8,11 +8,18 @@ repository.workspace = true
description = "Reusable local voice primitives for Buzz"
[dependencies]
+atomic-write-file = "0.3"
+hex = { workspace = true }
ort = { version = "=2.0.0-rc.12", default-features = false, features = ["api-24", "ndarray", "std"] }
ort-sys = { version = "=2.0.0-rc.12", features = ["disable-linking"] }
rand = "0.10"
sentencepiece-model = "0.1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+sha2 = { workspace = true }
sherpa-onnx = "1.12"
+symphonia = { version = "0.5", default-features = false, features = ["aac", "aiff", "alac", "flac", "isomp4", "mp3", "ogg", "pcm", "vorbis", "wav"] }
tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] }
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/crates/buzz-voice/src/imported.rs b/crates/buzz-voice/src/imported.rs
new file mode 100644
index 000000000..6f0ea71ca
--- /dev/null
+++ b/crates/buzz-voice/src/imported.rs
@@ -0,0 +1,730 @@
+//! Device-local Pocket reference voice validation, canonicalization, and storage.
+
+use std::{
+ fs,
+ io::Write,
+ path::{Path, PathBuf},
+};
+
+use atomic_write_file::AtomicWriteFile;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use symphonia::core::{
+ audio::SampleBuffer, codecs::DecoderOptions, errors::Error as SymphoniaError,
+ formats::FormatOptions, io::MediaSourceStream, meta::MetadataOptions, probe::Hint,
+};
+
+const MAX_SOURCE_BYTES: u64 = 25 * 1024 * 1024;
+const MIN_SAMPLE_RATE: u32 = 8_000;
+const MAX_SAMPLE_RATE: u32 = 96_000;
+const MIN_DURATION_SECONDS: f64 = 2.0;
+const MAX_DURATION_SECONDS: f64 = 30.0;
+pub const CANONICAL_SAMPLE_RATE: u32 = 32_000;
+const REGISTRY_VERSION: u32 = 1;
+const REGISTRY_FILE: &str = "registry.json";
+
+#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
+#[serde(rename_all = "camelCase")]
+pub struct ImportedVoice {
+ pub key: String,
+ pub display_name: String,
+ pub content_hash: String,
+ pub file_name: String,
+}
+
+#[derive(Default, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ImportedVoiceRegistry {
+ version: u32,
+ voices: Vec,
+}
+
+#[derive(Clone, Debug)]
+pub struct PocketVoiceLibrary {
+ root: PathBuf,
+}
+
+impl PocketVoiceLibrary {
+ pub fn new(root: impl Into) -> Self {
+ Self { root: root.into() }
+ }
+
+ pub fn root(&self) -> &Path {
+ &self.root
+ }
+
+ fn registry_path(&self) -> PathBuf {
+ self.root.join(REGISTRY_FILE)
+ }
+
+ pub fn load(&self) -> Result, String> {
+ let path = self.registry_path();
+ if !path.exists() {
+ return Ok(Vec::new());
+ }
+ let bytes =
+ fs::read(&path).map_err(|error| format!("could not read imported voices: {error}"))?;
+ let registry: ImportedVoiceRegistry = serde_json::from_slice(&bytes)
+ .map_err(|error| format!("imported voice registry is invalid: {error}"))?;
+ if registry.version > REGISTRY_VERSION {
+ return Err(format!(
+ "imported voice registry version {} is newer than this Buzz build supports",
+ registry.version
+ ));
+ }
+ Ok(registry
+ .voices
+ .into_iter()
+ .filter(valid_identity)
+ .filter(|voice| self.resolve_file(voice).is_ok())
+ .collect())
+ }
+
+ fn save(&self, voices: &[ImportedVoice]) -> Result<(), String> {
+ ensure_storage_dir(&self.root)?;
+ let payload = serde_json::to_vec_pretty(&ImportedVoiceRegistry {
+ version: REGISTRY_VERSION,
+ voices: voices.to_vec(),
+ })
+ .map_err(|error| format!("could not encode imported voice registry: {error}"))?;
+ atomic_write_restricted(&self.registry_path(), &payload)
+ .map_err(|error| format!("could not save imported voice registry: {error}"))
+ }
+
+ pub fn resolve_file(&self, voice: &ImportedVoice) -> Result {
+ if !valid_identity(voice) {
+ return Err("Imported voice registry contains an invalid file identity".to_string());
+ }
+ let path = self.root.join(&voice.file_name);
+ if !is_regular_file_without_symlink(&path) {
+ return Err(format!("Imported voice {} is missing", voice.display_name));
+ }
+ let bytes =
+ fs::read(&path).map_err(|error| format!("could not verify imported voice: {error}"))?;
+ if hex::encode(Sha256::digest(bytes)) != voice.content_hash {
+ return Err(format!(
+ "Imported voice {} does not match its content identity",
+ voice.display_name
+ ));
+ }
+ Ok(path)
+ }
+
+ pub fn find(&self, key: &str) -> Result
)}
+ {
+ if (!open) setDeleteCandidate(null);
+ }}
+ open={deleteCandidate !== null}
+ >
+
+
+ Delete imported voice?
+
+ {deleteCandidate
+ ? `${deleteCandidate.displayName} and its local audio file will be removed.`
+ : "This imported voice and its local audio file will be removed."}
+ {selectedVoice?.key === deleteCandidate?.key &&
+ " Mary will be selected instead."}
+
+
+
+ Cancel
+ {
+ event.preventDefault();
+ if (deleteCandidate) {
+ void deletePocketVoice(deleteCandidate.key);
+ }
+ }}
+ >
+ Delete voice
+
+
+
+
);
}
diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts
index 97051b68e..818144415 100644
--- a/desktop/src/testing/e2eBridge.ts
+++ b/desktop/src/testing/e2eBridge.ts
@@ -166,6 +166,8 @@ type E2eConfig = {
agentTextToSpeech: boolean;
voicePreferences: string[];
};
+ /** Native picker boundary result for Pocket voice import tests. */
+ pocketVoiceImportResult?: "success" | "cancel" | "invalid";
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
/** Builderlab account returned by hosted-community onboarding. Null/omitted = signed out. */
@@ -9911,7 +9913,25 @@ export function maybeInstallE2eTauriMocks() {
deviceId: state === "running" ? "mock-endpoint-id" : null,
deviceName: state === "running" ? "Mock desktop" : null,
});
- const handleMockCommand = async (command: string, payload: unknown) => {
+ let mockImportedVoices: Array<{
+ key: string;
+ displayName: string;
+ backend: string;
+ backendName: string;
+ availability: "installed";
+ fallbackKey: string;
+ referenceFile: string;
+ provenance: {
+ source: string;
+ contentHash: string;
+ license: null;
+ sourceUrl: null;
+ };
+ }> = [];
+ const handleMockCommand = async (
+ command: string,
+ payload: unknown,
+ ): Promise => {
const activeConfig = getConfig();
const identity = getActiveIdentity(activeConfig);
window.__BUZZ_E2E_COMMANDS__?.push(command);
@@ -9969,107 +9989,110 @@ export function maybeInstallE2eTauriMocks() {
);
case "list_voice_registry":
return [
- [
- "anna",
- "Anna",
- "anna.wav",
- "p228_023_enhanced.wav",
- "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856",
- ],
- [
- "vera",
- "Vera",
- "vera.wav",
- "p229_023_enhanced.wav",
- "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b",
- ],
- [
- "fantine",
- "Fantine",
- "fantine.wav",
- "p244_023_enhanced.wav",
- "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b",
- ],
- [
- "charles",
- "Charles",
- "charles.wav",
- "p254_023_enhanced.wav",
- "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756",
- ],
- [
- "paul",
- "Paul",
- "paul.wav",
- "p259_023_enhanced.wav",
- "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b",
- ],
- [
- "eponine",
- "Eponine",
- "eponine.wav",
- "p262_023_enhanced.wav",
- "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b",
- ],
- [
- "azelma",
- "Azelma",
- "azelma.wav",
- "p303_023_enhanced.wav",
- "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026",
- ],
- [
- "george",
- "George",
- "george.wav",
- "p315_023_enhanced.wav",
- "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae",
- ],
- [
- "mary",
- "Mary",
- "reference_sample.wav",
- "p333_023_enhanced.wav",
- "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f",
- ],
- [
- "jane",
- "Jane",
- "jane.wav",
- "p339_023_enhanced.wav",
- "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a",
- ],
- [
- "michael",
- "Michael",
- "michael.wav",
- "p360_023_enhanced.wav",
- "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad",
- ],
- [
- "eve",
- "Eve",
- "eve.wav",
- "p361_023_enhanced.wav",
- "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd",
- ],
- ].map(
- ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({
- key: `pocket:${id}`,
- displayName,
- backend: "pocket",
- backendName: "Pocket TTS",
- availability: "bundled",
- fallbackKey: id === "mary" ? null : "pocket:mary",
- referenceFile,
- provenance: {
- source: "bundled",
- contentHash,
- license: "CC-BY-4.0",
- sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`,
- },
- }),
- );
+ ...[
+ [
+ "anna",
+ "Anna",
+ "anna.wav",
+ "p228_023_enhanced.wav",
+ "0a6de25cf12bf1540beb85979f306a92be81fecc051c547c5395e7e5237a3856",
+ ],
+ [
+ "vera",
+ "Vera",
+ "vera.wav",
+ "p229_023_enhanced.wav",
+ "309cf91a895830f15842b398f69a4962cb1f7e0bfab10e25dd27838e826c204b",
+ ],
+ [
+ "fantine",
+ "Fantine",
+ "fantine.wav",
+ "p244_023_enhanced.wav",
+ "5f07d4e2a3f20a15572aae885156b43ef3fc12ef3812996fd135680d9956448b",
+ ],
+ [
+ "charles",
+ "Charles",
+ "charles.wav",
+ "p254_023_enhanced.wav",
+ "6b681a429198f16e378d53bccb08d06939da7b00144a7696111d4f8f76be7756",
+ ],
+ [
+ "paul",
+ "Paul",
+ "paul.wav",
+ "p259_023_enhanced.wav",
+ "7aba504fe0b3b16478b69ed27ce6007e3cb42b0c1915b5f1c6a6024ae37d679b",
+ ],
+ [
+ "eponine",
+ "Eponine",
+ "eponine.wav",
+ "p262_023_enhanced.wav",
+ "a13c27fb47627b05223691a0ef2974358a18c886e6c2f9d2762ff1d02c20926b",
+ ],
+ [
+ "azelma",
+ "Azelma",
+ "azelma.wav",
+ "p303_023_enhanced.wav",
+ "60e3d26cdf2efdec5df712152c839928f4d5522821e6554ae11fd96c57ab1026",
+ ],
+ [
+ "george",
+ "George",
+ "george.wav",
+ "p315_023_enhanced.wav",
+ "29a41f93bf5236e5b21501091d7774c255d5f3d4e62fa4f9fdf0a92a793c84ae",
+ ],
+ [
+ "mary",
+ "Mary",
+ "reference_sample.wav",
+ "p333_023_enhanced.wav",
+ "a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f",
+ ],
+ [
+ "jane",
+ "Jane",
+ "jane.wav",
+ "p339_023_enhanced.wav",
+ "2f12e7f155eb3118f55425394f1b049e5b1b67bdc9b3932c8ba4521420aeb84a",
+ ],
+ [
+ "michael",
+ "Michael",
+ "michael.wav",
+ "p360_023_enhanced.wav",
+ "b6743e9195e5e3fd34fe9d1633ae93f7ffab787b249e45f6467d7d6f7a6ee6ad",
+ ],
+ [
+ "eve",
+ "Eve",
+ "eve.wav",
+ "p361_023_enhanced.wav",
+ "396e7cbd066b0f3fb6d67fa26e7904076958239d736d4390f15b5fe88feb14cd",
+ ],
+ ].map(
+ ([id, displayName, referenceFile, upstreamFile, contentHash]) => ({
+ key: `pocket:${id}`,
+ displayName,
+ backend: "pocket",
+ backendName: "Pocket TTS",
+ availability: "bundled",
+ fallbackKey: id === "mary" ? null : "pocket:mary",
+ referenceFile,
+ provenance: {
+ source: "bundled",
+ contentHash,
+ license: "CC-BY-4.0",
+ sourceUrl: `https://huggingface.co/kyutai/tts-voices/blob/323332d33f997de8394f24a193e1a76df720e01a/vctk/${upstreamFile}`,
+ },
+ }),
+ ),
+ ...mockImportedVoices,
+ ];
case "set_tts_enabled": {
const enabled = (payload as { enabled?: boolean })?.enabled;
if (typeof enabled !== "boolean")
@@ -10114,6 +10137,75 @@ export function maybeInstallE2eTauriMocks() {
}
case "preview_pocket_voice":
return null;
+ case "import_pocket_voice": {
+ const importResult =
+ activeConfig?.mock?.pocketVoiceImportResult ?? "success";
+ if (importResult === "cancel") return null;
+ if (importResult === "invalid") {
+ throw new Error("Voice WAV must contain PCM or 32-bit float audio");
+ }
+ const contentHash = "1".repeat(64);
+ const imported = {
+ key: `pocket:imported:${contentHash}`,
+ displayName: "My voice",
+ backend: "pocket",
+ backendName: "Pocket TTS",
+ availability: "installed" as const,
+ fallbackKey: "pocket:mary",
+ referenceFile: `${contentHash}.wav`,
+ provenance: {
+ source: "local import",
+ contentHash,
+ license: null,
+ sourceUrl: null,
+ },
+ };
+ mockImportedVoices = [imported];
+ const current = activeConfig?.mock?.ttsSettings ?? {
+ version: 1,
+ agentTextToSpeech: true,
+ voicePreferences: ["pocket:mary"],
+ };
+ const settings = {
+ ...current,
+ voicePreferences: [imported.key],
+ };
+ if (activeConfig) {
+ activeConfig.mock ??= {};
+ activeConfig.mock.ttsSettings = settings;
+ }
+ return {
+ settings,
+ registry: await handleMockCommand("list_voice_registry", null),
+ };
+ }
+ case "delete_pocket_voice": {
+ const voiceKey = (payload as { voiceKey?: string })?.voiceKey;
+ if (!voiceKey?.startsWith("pocket:imported:"))
+ throw new Error("Missing imported Pocket voice key");
+ mockImportedVoices = mockImportedVoices.filter(
+ (voice) => voice.key !== voiceKey,
+ );
+ const current = activeConfig?.mock?.ttsSettings ?? {
+ version: 1,
+ agentTextToSpeech: true,
+ voicePreferences: ["pocket:mary"],
+ };
+ const settings = {
+ ...current,
+ voicePreferences: current.voicePreferences.includes(voiceKey)
+ ? ["pocket:mary"]
+ : current.voicePreferences,
+ };
+ if (activeConfig) {
+ activeConfig.mock ??= {};
+ activeConfig.mock.ttsSettings = settings;
+ }
+ return {
+ settings,
+ registry: await handleMockCommand("list_voice_registry", null),
+ };
+ }
case "get_builderlab_auth":
return activeConfig?.mock?.builderlabAuth ?? null;
case "start_builderlab_login": {
diff --git a/desktop/tests/e2e/voice-settings.spec.ts b/desktop/tests/e2e/voice-settings.spec.ts
index 490bee342..9bd8742ed 100644
--- a/desktop/tests/e2e/voice-settings.spec.ts
+++ b/desktop/tests/e2e/voice-settings.spec.ts
@@ -116,4 +116,97 @@ test.describe("Pocket voice settings", () => {
},
});
});
+
+ 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();
+ });
});
diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts
index 2d41d27a5..8a9ab2be1 100644
--- a/desktop/tests/helpers/bridge.ts
+++ b/desktop/tests/helpers/bridge.ts
@@ -164,6 +164,8 @@ type MockBridgeOptions = {
agentTextToSpeech: boolean;
voicePreferences: string[];
};
+ /** Native picker boundary result for Pocket voice import tests. */
+ pocketVoiceImportResult?: "success" | "cancel" | "invalid";
/** Advertised HEAD for the first mock project without adding that branch. */
projectHeadBranch?: string;
/** Relay NIP-11 identity used to sign authoritative repository state. */