diff --git a/Cargo.lock b/Cargo.lock
index 5d9a2d7646..9a3f91671d 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 3574c3291e..beff5b4a54 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 0000000000..6f0ea71cad
--- /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 97051b68ec..818144415a 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 490bee3424..9bd8742ed7 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 2d41d27a5e..8a9ab2be11 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. */