mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
test(desktop): automate imported voice audio workflow
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
@@ -8,11 +8,20 @@ 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"
|
||||
tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dev-dependencies]
|
||||
cpal = "0.17"
|
||||
|
||||
@@ -0,0 +1,549 @@
|
||||
//! 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};
|
||||
|
||||
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<ImportedVoice>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PocketVoiceLibrary {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl PocketVoiceLibrary {
|
||||
pub fn new(root: impl Into<PathBuf>) -> 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<Vec<ImportedVoice>, 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<PathBuf, String> {
|
||||
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<Option<ImportedVoice>, String> {
|
||||
Ok(self.load()?.into_iter().find(|voice| voice.key == key))
|
||||
}
|
||||
|
||||
pub fn import_path(&self, source: &Path) -> Result<ImportedVoice, String> {
|
||||
let metadata = fs::metadata(source)
|
||||
.map_err(|error| format!("could not inspect selected WAV: {error}"))?;
|
||||
if metadata.len() > MAX_SOURCE_BYTES {
|
||||
return Err("Voice WAV must be 25 MB or smaller".to_string());
|
||||
}
|
||||
let source_bytes =
|
||||
fs::read(source).map_err(|error| format!("could not read selected WAV: {error}"))?;
|
||||
let samples = decode_wav(&source_bytes)?;
|
||||
let canonical_samples = resample_linear(&samples.samples, samples.sample_rate);
|
||||
let canonical = encode_pcm16_wav(&canonical_samples, CANONICAL_SAMPLE_RATE);
|
||||
let hash = hex::encode(Sha256::digest(&canonical));
|
||||
let key = format!("pocket:imported:{hash}");
|
||||
let file_name = format!("{hash}.wav");
|
||||
let display_name = source
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("Imported voice")
|
||||
.chars()
|
||||
.take(80)
|
||||
.collect::<String>();
|
||||
|
||||
ensure_storage_dir(&self.root)?;
|
||||
let file_path = self.root.join(&file_name);
|
||||
let file_created = !file_path.exists();
|
||||
if file_created {
|
||||
atomic_write_restricted(&file_path, &canonical)
|
||||
.map_err(|error| format!("could not save imported voice audio: {error}"))?;
|
||||
} else {
|
||||
if !is_regular_file_without_symlink(&file_path) {
|
||||
return Err("Imported voice storage contains an unsafe file entry".to_string());
|
||||
}
|
||||
let existing = fs::read(&file_path)
|
||||
.map_err(|error| format!("could not verify imported voice audio: {error}"))?;
|
||||
if hex::encode(Sha256::digest(&existing)) != hash {
|
||||
return Err("Imported voice storage contains mismatched audio data".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported = ImportedVoice {
|
||||
key,
|
||||
display_name,
|
||||
content_hash: hash,
|
||||
file_name,
|
||||
};
|
||||
let mut voices = self.load()?;
|
||||
if let Some(existing) = voices
|
||||
.iter()
|
||||
.find(|voice| voice.content_hash == imported.content_hash)
|
||||
{
|
||||
imported = existing.clone();
|
||||
} else {
|
||||
voices.push(imported.clone());
|
||||
}
|
||||
if let Err(error) = self.save(&voices) {
|
||||
if file_created {
|
||||
let _ = fs::remove_file(&file_path);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) -> Result<(), String> {
|
||||
let mut voices = self.load()?;
|
||||
let index = voices
|
||||
.iter()
|
||||
.position(|voice| voice.key == key)
|
||||
.ok_or_else(|| format!("Unknown imported voice: {key}"))?;
|
||||
let previous_voices = voices.clone();
|
||||
let removed = voices.remove(index);
|
||||
self.save(&voices)?;
|
||||
let path = self.root.join(removed.file_name);
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => {
|
||||
self.save(&previous_voices).map_err(|rollback_error| {
|
||||
format!(
|
||||
"Imported voice audio could not be deleted ({error}), and its registry \
|
||||
entry could not be restored ({rollback_error})"
|
||||
)
|
||||
})?;
|
||||
Err(format!(
|
||||
"Imported voice audio could not be deleted: {error}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PcmStats {
|
||||
pub sample_count: usize,
|
||||
pub sample_rate: u32,
|
||||
pub duration_seconds: f64,
|
||||
pub peak: f32,
|
||||
pub rms: f32,
|
||||
pub non_silent_samples: usize,
|
||||
}
|
||||
|
||||
impl PcmStats {
|
||||
pub fn analyze(samples: &[f32], sample_rate: u32) -> Self {
|
||||
let peak = samples
|
||||
.iter()
|
||||
.filter(|sample| sample.is_finite())
|
||||
.fold(0.0_f32, |peak, sample| peak.max(sample.abs()));
|
||||
let square_sum = samples
|
||||
.iter()
|
||||
.filter(|sample| sample.is_finite())
|
||||
.map(|sample| sample * sample)
|
||||
.sum::<f32>();
|
||||
let rms = if samples.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
(square_sum / samples.len() as f32).sqrt()
|
||||
};
|
||||
Self {
|
||||
sample_count: samples.len(),
|
||||
sample_rate,
|
||||
duration_seconds: if sample_rate == 0 {
|
||||
0.0
|
||||
} else {
|
||||
samples.len() as f64 / f64::from(sample_rate)
|
||||
},
|
||||
peak,
|
||||
rms,
|
||||
non_silent_samples: samples
|
||||
.iter()
|
||||
.filter(|sample| sample.is_finite() && sample.abs() >= 0.001)
|
||||
.count(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_non_silent(self) -> bool {
|
||||
self.peak >= 0.001 && self.rms >= 0.0001 && self.non_silent_samples > 0
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_pcm16_wav(path: &Path, samples: &[f32], sample_rate: u32) -> Result<(), String> {
|
||||
let bytes = encode_pcm16_wav(samples, sample_rate);
|
||||
fs::write(path, bytes).map_err(|error| format!("could not write PCM evidence: {error}"))
|
||||
}
|
||||
|
||||
fn ensure_storage_dir(path: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|error| format!("could not create local voice storage: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("could not restrict local voice storage: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn atomic_write_restricted(path: &Path, payload: &[u8]) -> Result<(), String> {
|
||||
let resolved = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
let mut file = AtomicWriteFile::open(&resolved)
|
||||
.map_err(|error| format!("open {} for atomic write: {error}", resolved.display()))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|error| format!("set {} permissions: {error}", resolved.display()))?;
|
||||
}
|
||||
file.write_all(payload)
|
||||
.map_err(|error| format!("write {}: {error}", resolved.display()))?;
|
||||
file.commit()
|
||||
.map_err(|error| format!("commit {}: {error}", resolved.display()))
|
||||
}
|
||||
|
||||
fn valid_hash(hash: &str) -> bool {
|
||||
hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn valid_identity(voice: &ImportedVoice) -> bool {
|
||||
valid_hash(&voice.content_hash)
|
||||
&& voice.key == format!("pocket:imported:{}", voice.content_hash)
|
||||
&& voice.file_name == format!("{}.wav", voice.content_hash)
|
||||
}
|
||||
|
||||
fn is_regular_file_without_symlink(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path)
|
||||
.is_ok_and(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DecodedWav {
|
||||
sample_rate: u32,
|
||||
samples: Vec<f32>,
|
||||
}
|
||||
|
||||
fn decode_wav(bytes: &[u8]) -> Result<DecodedWav, String> {
|
||||
if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
|
||||
return Err("Selected file is not a valid RIFF/WAVE file".to_string());
|
||||
}
|
||||
let mut offset = 12usize;
|
||||
let mut format = None;
|
||||
let mut data = None;
|
||||
while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) {
|
||||
let id = &bytes[offset..offset + 4];
|
||||
let size =
|
||||
u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap_or([0; 4])) as usize;
|
||||
let start = offset + 8;
|
||||
let end = start.checked_add(size).ok_or("WAV chunk size overflow")?;
|
||||
if end > bytes.len() {
|
||||
return Err("Selected WAV contains a truncated chunk".to_string());
|
||||
}
|
||||
if id == b"fmt " {
|
||||
format = Some(&bytes[start..end]);
|
||||
} else if id == b"data" {
|
||||
data = Some(&bytes[start..end]);
|
||||
}
|
||||
offset = end + (size & 1);
|
||||
}
|
||||
let format = format.ok_or("Selected WAV has no format chunk")?;
|
||||
let data = data.ok_or("Selected WAV has no audio data")?;
|
||||
if format.len() < 16 {
|
||||
return Err("Selected WAV has an invalid format chunk".to_string());
|
||||
}
|
||||
let encoding = u16::from_le_bytes(format[0..2].try_into().unwrap_or([0; 2]));
|
||||
let encoding = if encoding == 0xfffe && format.len() >= 40 {
|
||||
u16::from_le_bytes(format[24..26].try_into().unwrap_or([0; 2]))
|
||||
} else {
|
||||
encoding
|
||||
};
|
||||
let channels = u16::from_le_bytes(format[2..4].try_into().unwrap_or([0; 2]));
|
||||
let sample_rate = u32::from_le_bytes(format[4..8].try_into().unwrap_or([0; 4]));
|
||||
let block_align = u16::from_le_bytes(format[12..14].try_into().unwrap_or([0; 2])) as usize;
|
||||
let bits = u16::from_le_bytes(format[14..16].try_into().unwrap_or([0; 2]));
|
||||
if channels != 1 {
|
||||
return Err("Voice WAV must be mono".to_string());
|
||||
}
|
||||
if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sample_rate) {
|
||||
return Err("Voice WAV sample rate must be between 8 and 96 kHz".to_string());
|
||||
}
|
||||
let bytes_per_sample = usize::from(bits.div_ceil(8));
|
||||
if block_align != bytes_per_sample || block_align == 0 || data.len() % block_align != 0 {
|
||||
return Err("Voice WAV has invalid sample alignment".to_string());
|
||||
}
|
||||
if !matches!((encoding, bits), (1, 8 | 16 | 24 | 32) | (3, 32)) {
|
||||
return Err("Voice WAV must contain PCM or 32-bit float audio".to_string());
|
||||
}
|
||||
let frames = data.len() / block_align;
|
||||
let duration = frames as f64 / f64::from(sample_rate);
|
||||
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration) {
|
||||
return Err("Voice WAV must be between 2 and 30 seconds long".to_string());
|
||||
}
|
||||
|
||||
let mut samples = Vec::with_capacity(frames);
|
||||
for chunk in data.chunks_exact(block_align) {
|
||||
let sample = match (encoding, bits) {
|
||||
(1, 8) => (f32::from(chunk[0]) - 128.0) / 128.0,
|
||||
(1, 16) => f32::from(i16::from_le_bytes([chunk[0], chunk[1]])) / 32768.0,
|
||||
(1, 24) => {
|
||||
let raw = i32::from_le_bytes([
|
||||
chunk[0],
|
||||
chunk[1],
|
||||
chunk[2],
|
||||
if chunk[2] & 0x80 == 0 { 0 } else { 0xff },
|
||||
]);
|
||||
raw as f32 / 8_388_608.0
|
||||
}
|
||||
(1, 32) => {
|
||||
i32::from_le_bytes(chunk.try_into().map_err(|_| "invalid PCM sample")?) as f32
|
||||
/ 2_147_483_648.0
|
||||
}
|
||||
(3, 32) => f32::from_le_bytes(
|
||||
chunk
|
||||
.try_into()
|
||||
.map_err(|_| "invalid floating-point sample")?,
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if !sample.is_finite() {
|
||||
return Err("Voice WAV contains non-finite samples".to_string());
|
||||
}
|
||||
samples.push(sample.clamp(-1.0, 1.0));
|
||||
}
|
||||
let stats = PcmStats::analyze(&samples, sample_rate);
|
||||
if !stats.is_non_silent() {
|
||||
return Err("Voice WAV is silent or too quiet to clone".to_string());
|
||||
}
|
||||
Ok(DecodedWav {
|
||||
sample_rate,
|
||||
samples,
|
||||
})
|
||||
}
|
||||
|
||||
fn resample_linear(samples: &[f32], source_rate: u32) -> Vec<f32> {
|
||||
if source_rate == CANONICAL_SAMPLE_RATE {
|
||||
return samples.to_vec();
|
||||
}
|
||||
let output_len = ((samples.len() as u64 * u64::from(CANONICAL_SAMPLE_RATE)
|
||||
+ u64::from(source_rate) / 2)
|
||||
/ u64::from(source_rate)) as usize;
|
||||
(0..output_len)
|
||||
.map(|index| {
|
||||
let source = index as f64 * f64::from(source_rate) / f64::from(CANONICAL_SAMPLE_RATE);
|
||||
let left = source.floor() as usize;
|
||||
let fraction = (source - left as f64) as f32;
|
||||
let a = samples[left.min(samples.len() - 1)];
|
||||
let b = samples[(left + 1).min(samples.len() - 1)];
|
||||
a + (b - a) * fraction
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn encode_pcm16_wav(samples: &[f32], sample_rate: u32) -> Vec<u8> {
|
||||
let data_len = (samples.len() * 2) as u32;
|
||||
let mut bytes = Vec::with_capacity(44 + data_len as usize);
|
||||
bytes.extend_from_slice(b"RIFF");
|
||||
bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
bytes.extend_from_slice(b"WAVEfmt ");
|
||||
bytes.extend_from_slice(&16_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&1_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes());
|
||||
bytes.extend_from_slice(&2_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&16_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(b"data");
|
||||
bytes.extend_from_slice(&data_len.to_le_bytes());
|
||||
for sample in samples {
|
||||
let value = (sample.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16;
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(sample_rate: u32, seconds: usize, amplitude: f32) -> Vec<u8> {
|
||||
let samples = (0..sample_rate as usize * seconds)
|
||||
.map(|index| {
|
||||
amplitude
|
||||
* (std::f32::consts::TAU * 220.0 * index as f32 / sample_rate as f32).sin()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
encode_pcm16_wav(&samples, sample_rate)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_persists_reloads_and_deletes_canonical_voice() {
|
||||
let temp = tempfile::tempdir().expect("temp voice workspace");
|
||||
let source = temp.path().join("My voice.wav");
|
||||
fs::write(&source, fixture(44_100, 2, 0.5)).expect("write source");
|
||||
let library = PocketVoiceLibrary::new(temp.path().join("library"));
|
||||
|
||||
let imported = library.import_path(&source).expect("import voice");
|
||||
assert!(imported.key.starts_with("pocket:imported:"));
|
||||
assert_eq!(imported.display_name, "My voice");
|
||||
|
||||
let relaunched = PocketVoiceLibrary::new(library.root());
|
||||
assert_eq!(
|
||||
relaunched.load().expect("reload registry"),
|
||||
vec![imported.clone()]
|
||||
);
|
||||
let stored = relaunched
|
||||
.resolve_file(&imported)
|
||||
.expect("resolve stored voice");
|
||||
let decoded = decode_wav(&fs::read(&stored).expect("read stored voice"))
|
||||
.expect("decode canonical voice");
|
||||
assert_eq!(decoded.sample_rate, CANONICAL_SAMPLE_RATE);
|
||||
assert_eq!(decoded.samples.len(), CANONICAL_SAMPLE_RATE as usize * 2);
|
||||
|
||||
assert_eq!(
|
||||
relaunched.import_path(&source).expect("idempotent import"),
|
||||
imported
|
||||
);
|
||||
assert_eq!(relaunched.load().expect("deduplicated registry").len(), 1);
|
||||
|
||||
relaunched.delete(&imported.key).expect("delete voice");
|
||||
assert!(relaunched.load().expect("empty registry").is_empty());
|
||||
assert!(!stored.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_unsupported_and_silent_files_do_not_mutate_registry() {
|
||||
let temp = tempfile::tempdir().expect("temp voice workspace");
|
||||
let library = PocketVoiceLibrary::new(temp.path().join("library"));
|
||||
|
||||
let garbage = temp.path().join("garbage.wav");
|
||||
fs::write(&garbage, b"not a wave").expect("write garbage");
|
||||
assert!(library
|
||||
.import_path(&garbage)
|
||||
.expect_err("garbage rejected")
|
||||
.contains("RIFF/WAVE"));
|
||||
|
||||
let silent = temp.path().join("silent.wav");
|
||||
fs::write(&silent, fixture(32_000, 2, 0.0)).expect("write silence");
|
||||
assert!(library
|
||||
.import_path(&silent)
|
||||
.expect_err("silence rejected")
|
||||
.contains("silent"));
|
||||
|
||||
let mut stereo = fixture(32_000, 2, 0.5);
|
||||
stereo[22..24].copy_from_slice(&2_u16.to_le_bytes());
|
||||
let stereo_path = temp.path().join("stereo.wav");
|
||||
fs::write(&stereo_path, stereo).expect("write stereo");
|
||||
assert!(library
|
||||
.import_path(&stereo_path)
|
||||
.expect_err("stereo rejected")
|
||||
.contains("mono"));
|
||||
|
||||
let mut unsupported = fixture(32_000, 2, 0.5);
|
||||
unsupported[20..22].copy_from_slice(&6_u16.to_le_bytes());
|
||||
let unsupported_path = temp.path().join("unsupported.wav");
|
||||
fs::write(&unsupported_path, unsupported).expect("write unsupported");
|
||||
assert!(library
|
||||
.import_path(&unsupported_path)
|
||||
.expect_err("unsupported rejected")
|
||||
.contains("PCM or 32-bit float"));
|
||||
|
||||
assert!(library.load().expect("unchanged registry").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcm_analysis_distinguishes_signal_from_silence() {
|
||||
let signal = (0..24_000)
|
||||
.map(|index| (std::f32::consts::TAU * 440.0 * index as f32 / 24_000.0).sin() * 0.5)
|
||||
.collect::<Vec<_>>();
|
||||
let signal_stats = PcmStats::analyze(&signal, 24_000);
|
||||
assert!(signal_stats.is_non_silent());
|
||||
assert_eq!(signal_stats.duration_seconds, 1.0);
|
||||
assert!(signal_stats.peak > 0.49);
|
||||
assert!(signal_stats.rms > 0.3);
|
||||
|
||||
let silence = vec![0.0; 24_000];
|
||||
assert!(!PcmStats::analyze(&silence, 24_000).is_non_silent());
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Reusable local voice primitives for Buzz.
|
||||
|
||||
pub mod imported;
|
||||
pub mod pocket;
|
||||
|
||||
pub use pocket::{
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
},
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use buzz_voice::{
|
||||
imported::{write_pcm16_wav, PcmStats, PocketVoiceLibrary},
|
||||
pocket::{
|
||||
load_text_to_speech, load_voice_style, SynthesisOutcome, DEFAULT_VOICE, SAMPLE_RATE,
|
||||
VOICE_FILE_EXT,
|
||||
},
|
||||
};
|
||||
|
||||
const PREVIEW_TEXT: &str = "This is an objective Pocket voice preview.";
|
||||
const INTERRUPT_TEXT: &str =
|
||||
"This longer Pocket voice sentence should stop before it produces audio that could be played.";
|
||||
|
||||
fn required_path(name: &str) -> PathBuf {
|
||||
std::env::var_os(name)
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| panic!("{name} must point to the required local test path"))
|
||||
}
|
||||
|
||||
fn checked_in_voice() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../desktop/src-tauri/resources/pocket-voices/marius.wav")
|
||||
}
|
||||
|
||||
fn evidence_dir() -> PathBuf {
|
||||
std::env::var_os("BUZZ_VOICE_EVIDENCE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/buzz-voice-evidence")
|
||||
})
|
||||
}
|
||||
|
||||
fn synthesize(model_dir: &Path, voice_path: &Path, text: &str) -> (Vec<f32>, PcmStats) {
|
||||
let engine = load_text_to_speech(
|
||||
model_dir
|
||||
.to_str()
|
||||
.expect("Pocket model path must be valid UTF-8"),
|
||||
)
|
||||
.expect("load Pocket model");
|
||||
let style = load_voice_style(voice_path).expect("load selected voice");
|
||||
let samples = engine
|
||||
.synth_chunk(text, "en", &style, 1)
|
||||
.expect("synthesize preview");
|
||||
let stats = PcmStats::analyze(&samples, SAMPLE_RATE);
|
||||
assert!(
|
||||
stats.is_non_silent(),
|
||||
"generated PCM must be non-silent: {stats:?}"
|
||||
);
|
||||
assert!(
|
||||
stats.duration_seconds > 0.2,
|
||||
"generated PCM is unexpectedly short: {stats:?}"
|
||||
);
|
||||
(samples, stats)
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires BUZZ_POCKET_MODEL_DIR and runs the installed Pocket ONNX model"]
|
||||
fn objective_import_synthesis_delete_and_mary_fallback() {
|
||||
let model_dir = required_path("BUZZ_POCKET_MODEL_DIR");
|
||||
let temp = tempfile::tempdir().expect("temporary voice workspace");
|
||||
let source = temp.path().join("Imported Marius.wav");
|
||||
fs::copy(checked_in_voice(), &source).expect("copy checked-in voice fixture");
|
||||
|
||||
let library_root = temp.path().join("library");
|
||||
let library = PocketVoiceLibrary::new(&library_root);
|
||||
let imported = library.import_path(&source).expect("import valid WAV");
|
||||
assert_eq!(
|
||||
library.find(&imported.key).expect("read selection"),
|
||||
Some(imported.clone())
|
||||
);
|
||||
|
||||
drop(library);
|
||||
let relaunched = PocketVoiceLibrary::new(&library_root);
|
||||
let selected = relaunched
|
||||
.find(&imported.key)
|
||||
.expect("reload persisted selection")
|
||||
.expect("selected imported voice survived relaunch");
|
||||
let imported_path = relaunched
|
||||
.resolve_file(&selected)
|
||||
.expect("resolve persisted imported voice");
|
||||
let (imported_pcm, imported_stats) = synthesize(&model_dir, &imported_path, PREVIEW_TEXT);
|
||||
|
||||
let evidence = evidence_dir();
|
||||
fs::create_dir_all(&evidence).expect("create evidence directory");
|
||||
let imported_wav = evidence.join("imported-preview.wav");
|
||||
write_pcm16_wav(&imported_wav, &imported_pcm, SAMPLE_RATE)
|
||||
.expect("write imported preview evidence");
|
||||
|
||||
let engine = load_text_to_speech(
|
||||
model_dir
|
||||
.to_str()
|
||||
.expect("Pocket model path must be valid UTF-8"),
|
||||
)
|
||||
.expect("load Pocket model for interruption");
|
||||
let style = load_voice_style(&imported_path).expect("load imported style for interruption");
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
let interrupt_worker = Arc::clone(&interrupted);
|
||||
let interrupter = thread::spawn(move || {
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
interrupt_worker.store(true, Ordering::Release);
|
||||
});
|
||||
let interrupt_check = Arc::clone(&interrupted);
|
||||
let outcome = engine
|
||||
.synth_chunk_interruptible(INTERRUPT_TEXT, "en", &style, 1, move || {
|
||||
interrupt_check.load(Ordering::Acquire)
|
||||
})
|
||||
.expect("interrupt synthesis");
|
||||
interrupter.join().expect("join interrupter");
|
||||
assert_eq!(
|
||||
outcome,
|
||||
SynthesisOutcome::Interrupted,
|
||||
"interrupted synthesis must discard partial PCM"
|
||||
);
|
||||
|
||||
assert!(
|
||||
engine
|
||||
.synth_chunk("", "en", &style, 1)
|
||||
.expect("empty synthesis")
|
||||
.is_empty(),
|
||||
"empty input must not emit unintended audio"
|
||||
);
|
||||
|
||||
relaunched
|
||||
.delete(&imported.key)
|
||||
.expect("delete imported voice");
|
||||
assert_eq!(
|
||||
relaunched.find(&imported.key).expect("reload after delete"),
|
||||
None
|
||||
);
|
||||
|
||||
let mary_path = model_dir.join(format!("{DEFAULT_VOICE}.{VOICE_FILE_EXT}"));
|
||||
assert_eq!(
|
||||
mary_path.file_name().and_then(|name| name.to_str()),
|
||||
Some("reference_sample.wav"),
|
||||
"fallback must remain the deterministic Mary reference"
|
||||
);
|
||||
let (mary_pcm, mary_stats) = synthesize(&model_dir, &mary_path, PREVIEW_TEXT);
|
||||
let mary_wav = evidence.join("mary-fallback-preview.wav");
|
||||
write_pcm16_wav(&mary_wav, &mary_pcm, SAMPLE_RATE)
|
||||
.expect("write Mary fallback preview evidence");
|
||||
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"importedKey": imported.key,
|
||||
"persistence": "reloaded",
|
||||
"interruption": "partial PCM discarded",
|
||||
"emptyInputSamples": 0,
|
||||
"afterDelete": "pocket:mary",
|
||||
"importedPreview": {
|
||||
"path": imported_wav,
|
||||
"samples": imported_stats.sample_count,
|
||||
"sampleRate": imported_stats.sample_rate,
|
||||
"durationSeconds": imported_stats.duration_seconds,
|
||||
"peak": imported_stats.peak,
|
||||
"rms": imported_stats.rms,
|
||||
"nonSilentSamples": imported_stats.non_silent_samples,
|
||||
},
|
||||
"maryFallbackPreview": {
|
||||
"path": mary_wav,
|
||||
"samples": mary_stats.sample_count,
|
||||
"sampleRate": mary_stats.sample_rate,
|
||||
"durationSeconds": mary_stats.duration_seconds,
|
||||
"peak": mary_stats.peak,
|
||||
"rms": mary_stats.rms,
|
||||
"nonSilentSamples": mary_stats.non_silent_samples,
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod blackhole {
|
||||
use std::{
|
||||
sync::{Arc, Mutex},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use cpal::{
|
||||
self,
|
||||
traits::{DeviceTrait, HostTrait, StreamTrait},
|
||||
SampleFormat,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn named_device(
|
||||
devices: impl Iterator<Item = cpal::Device>,
|
||||
expected_name: &str,
|
||||
) -> cpal::Device {
|
||||
devices
|
||||
.filter_map(|device| {
|
||||
device
|
||||
.description()
|
||||
.ok()
|
||||
.map(|description| (device, description.name().to_string()))
|
||||
})
|
||||
.find(|(_, name)| name == expected_name)
|
||||
.map(|(device, _)| device)
|
||||
.unwrap_or_else(|| panic!("audio device {expected_name:?} is not available"))
|
||||
}
|
||||
|
||||
fn capture_f32(device: &cpal::Device, captured: Arc<Mutex<Vec<f32>>>) -> (cpal::Stream, u32) {
|
||||
let supported = device
|
||||
.default_input_config()
|
||||
.expect("read BlackHole input configuration");
|
||||
assert_eq!(
|
||||
supported.sample_format(),
|
||||
SampleFormat::F32,
|
||||
"BlackHole test expects Float32 input"
|
||||
);
|
||||
let sample_rate = supported.sample_rate();
|
||||
let config: cpal::StreamConfig = supported.into();
|
||||
let stream = device
|
||||
.build_input_stream(
|
||||
&config,
|
||||
move |data: &[f32], _| {
|
||||
captured
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.extend_from_slice(data);
|
||||
},
|
||||
|error| panic!("BlackHole capture failed: {error}"),
|
||||
None,
|
||||
)
|
||||
.expect("build BlackHole input stream");
|
||||
(stream, sample_rate)
|
||||
}
|
||||
|
||||
fn resample_linear(samples: &[f32], source_rate: u32, output_rate: u32) -> Vec<f32> {
|
||||
let output_len = ((samples.len() as u64 * u64::from(output_rate)
|
||||
+ u64::from(source_rate) / 2)
|
||||
/ u64::from(source_rate)) as usize;
|
||||
(0..output_len)
|
||||
.map(|index| {
|
||||
let source = index as f64 * f64::from(source_rate) / f64::from(output_rate);
|
||||
let left = source.floor() as usize;
|
||||
let fraction = (source - left as f64) as f32;
|
||||
let a = samples[left.min(samples.len() - 1)];
|
||||
let b = samples[(left + 1).min(samples.len() - 1)];
|
||||
a + (b - a) * fraction
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires BUZZ_POCKET_MODEL_DIR and BUZZ_VOICE_AUDIO_DEVICE=BlackHole 2ch"]
|
||||
fn blackhole_playback_captures_generated_non_silent_pcm() {
|
||||
let model_dir = required_path("BUZZ_POCKET_MODEL_DIR");
|
||||
let device_name = std::env::var("BUZZ_VOICE_AUDIO_DEVICE")
|
||||
.expect("BUZZ_VOICE_AUDIO_DEVICE must name a BlackHole loopback device");
|
||||
let temp = tempfile::tempdir().expect("temporary voice workspace");
|
||||
let source = temp.path().join("Imported Marius.wav");
|
||||
fs::copy(checked_in_voice(), &source).expect("copy checked-in voice fixture");
|
||||
let library = PocketVoiceLibrary::new(temp.path().join("library"));
|
||||
let imported = library.import_path(&source).expect("import voice");
|
||||
let selected = library
|
||||
.find(&imported.key)
|
||||
.expect("read selection")
|
||||
.expect("selected imported voice");
|
||||
let voice_path = library
|
||||
.resolve_file(&selected)
|
||||
.expect("resolve imported voice");
|
||||
let (samples, generated_stats) = synthesize(&model_dir, &voice_path, PREVIEW_TEXT);
|
||||
|
||||
let host = cpal::default_host();
|
||||
let input = named_device(
|
||||
host.input_devices().expect("enumerate input devices"),
|
||||
&device_name,
|
||||
);
|
||||
let output = named_device(
|
||||
host.output_devices().expect("enumerate output devices"),
|
||||
&device_name,
|
||||
);
|
||||
let captured = Arc::new(Mutex::new(Vec::new()));
|
||||
let (input_stream, input_sample_rate) = capture_f32(&input, Arc::clone(&captured));
|
||||
input_stream.play().expect("start BlackHole capture");
|
||||
|
||||
let supported_output = output
|
||||
.default_output_config()
|
||||
.expect("read BlackHole output configuration");
|
||||
assert_eq!(
|
||||
supported_output.sample_format(),
|
||||
SampleFormat::F32,
|
||||
"BlackHole test expects Float32 output"
|
||||
);
|
||||
let output_sample_rate = supported_output.sample_rate();
|
||||
let output_config: cpal::StreamConfig = supported_output.into();
|
||||
let output_channels = usize::from(output_config.channels);
|
||||
let playback = resample_linear(&samples, SAMPLE_RATE, output_sample_rate);
|
||||
let playback_len = playback.len();
|
||||
let mut playback_index = 0usize;
|
||||
let output_stream = output
|
||||
.build_output_stream(
|
||||
&output_config,
|
||||
move |data: &mut [f32], _| {
|
||||
for frame in data.chunks_mut(output_channels) {
|
||||
let sample = playback.get(playback_index).copied().unwrap_or(0.0);
|
||||
frame.fill(sample);
|
||||
playback_index = playback_index.saturating_add(1);
|
||||
}
|
||||
},
|
||||
|error| panic!("BlackHole playback failed: {error}"),
|
||||
None,
|
||||
)
|
||||
.expect("build BlackHole output stream");
|
||||
output_stream.play().expect("start BlackHole playback");
|
||||
let started = Instant::now();
|
||||
let playback_duration =
|
||||
Duration::from_secs_f64(playback_len as f64 / f64::from(output_sample_rate));
|
||||
while started.elapsed() < playback_duration {
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
thread::sleep(Duration::from_millis(250));
|
||||
drop(output_stream);
|
||||
drop(input_stream);
|
||||
|
||||
let captured_samples = captured
|
||||
.lock()
|
||||
.unwrap_or_else(|error| error.into_inner())
|
||||
.clone();
|
||||
let captured_stats = PcmStats::analyze(&captured_samples, input_sample_rate);
|
||||
assert!(
|
||||
captured_stats.is_non_silent(),
|
||||
"BlackHole must capture non-silent generated PCM: {captured_stats:?}"
|
||||
);
|
||||
assert!(
|
||||
captured_stats.non_silent_samples >= generated_stats.non_silent_samples / 4,
|
||||
"captured signal is unexpectedly sparse: generated={generated_stats:?}, captured={captured_stats:?}"
|
||||
);
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::json!({
|
||||
"device": device_name,
|
||||
"generated": {
|
||||
"samples": generated_stats.sample_count,
|
||||
"peak": generated_stats.peak,
|
||||
"rms": generated_stats.rms,
|
||||
},
|
||||
"captured": {
|
||||
"samples": captured_stats.sample_count,
|
||||
"sampleRate": captured_stats.sample_rate,
|
||||
"peak": captured_stats.peak,
|
||||
"rms": captured_stats.rms,
|
||||
"nonSilentSamples": captured_stats.non_silent_samples,
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,10 @@
|
||||
//! Local Pocket reference-voice import storage and WAV canonicalization.
|
||||
//! Tauri native-picker adapter for the reusable local Pocket voice library.
|
||||
|
||||
use std::{
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use buzz_voice_pkg::imported::{ImportedVoice, PocketVoiceLibrary};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::managed_agents::storage::atomic_write_json_restricted;
|
||||
|
||||
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;
|
||||
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<ImportedVoice>,
|
||||
}
|
||||
|
||||
pub fn voices_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
app.path()
|
||||
.app_data_dir()
|
||||
@@ -43,79 +12,16 @@ pub fn voices_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
.map_err(|error| format!("could not locate local voice storage: {error}"))
|
||||
}
|
||||
|
||||
fn registry_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
Ok(voices_dir(app)?.join(REGISTRY_FILE))
|
||||
}
|
||||
|
||||
fn ensure_storage_dir(path: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(path)
|
||||
.map_err(|error| format!("could not create local voice storage: {error}"))?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
|
||||
.map_err(|error| format!("could not restrict local voice storage: {error}"))?;
|
||||
}
|
||||
Ok(())
|
||||
fn library(app: &AppHandle) -> Result<PocketVoiceLibrary, String> {
|
||||
voices_dir(app).map(PocketVoiceLibrary::new)
|
||||
}
|
||||
|
||||
pub fn load_registry(app: &AppHandle) -> Result<Vec<ImportedVoice>, String> {
|
||||
let path = registry_path(app)?;
|
||||
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(|voice| {
|
||||
valid_hash(&voice.content_hash)
|
||||
&& voice.key == format!("pocket:imported:{}", voice.content_hash)
|
||||
&& voice.file_name == format!("{}.wav", voice.content_hash)
|
||||
})
|
||||
.filter(|voice| resolve_file(app, voice).is_ok())
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn save_registry(app: &AppHandle, voices: &[ImportedVoice]) -> Result<(), String> {
|
||||
let dir = voices_dir(app)?;
|
||||
ensure_storage_dir(&dir)?;
|
||||
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_json_restricted(&dir.join(REGISTRY_FILE), &payload)
|
||||
.map_err(|error| format!("could not save imported voice registry: {error}"))
|
||||
library(app)?.load()
|
||||
}
|
||||
|
||||
pub fn resolve_file(app: &AppHandle, voice: &ImportedVoice) -> Result<PathBuf, String> {
|
||||
if !valid_hash(&voice.content_hash) || voice.file_name != format!("{}.wav", voice.content_hash)
|
||||
{
|
||||
return Err("Imported voice registry contains an invalid file identity".to_string());
|
||||
}
|
||||
let path = voices_dir(app)?.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)
|
||||
library(app)?.resolve_file(voice)
|
||||
}
|
||||
|
||||
pub async fn pick_and_import(app: &AppHandle) -> Result<Option<ImportedVoice>, String> {
|
||||
@@ -138,314 +44,13 @@ pub async fn pick_and_import(app: &AppHandle) -> Result<Option<ImportedVoice>, S
|
||||
.as_path()
|
||||
.ok_or("the selected voice path is invalid")?
|
||||
.to_path_buf();
|
||||
let app = app.clone();
|
||||
tokio::task::spawn_blocking(move || import_path(&app, &path))
|
||||
let voice_library = library(app)?;
|
||||
tokio::task::spawn_blocking(move || voice_library.import_path(&path))
|
||||
.await
|
||||
.map_err(|error| format!("voice import task failed: {error}"))?
|
||||
.map(Some)
|
||||
}
|
||||
|
||||
pub fn import_path(app: &AppHandle, source: &Path) -> Result<ImportedVoice, String> {
|
||||
let metadata =
|
||||
fs::metadata(source).map_err(|error| format!("could not inspect selected WAV: {error}"))?;
|
||||
if metadata.len() > MAX_SOURCE_BYTES {
|
||||
return Err("Voice WAV must be 25 MB or smaller".to_string());
|
||||
}
|
||||
let source_bytes =
|
||||
fs::read(source).map_err(|error| format!("could not read selected WAV: {error}"))?;
|
||||
let samples = decode_wav(&source_bytes)?;
|
||||
let canonical_samples = resample_linear(&samples.samples, samples.sample_rate);
|
||||
let canonical = encode_pcm16_wav(&canonical_samples, CANONICAL_SAMPLE_RATE);
|
||||
let hash = hex::encode(Sha256::digest(&canonical));
|
||||
let key = format!("pocket:imported:{hash}");
|
||||
let file_name = format!("{hash}.wav");
|
||||
let display_name = source
|
||||
.file_stem()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("Imported voice")
|
||||
.chars()
|
||||
.take(80)
|
||||
.collect::<String>();
|
||||
|
||||
let dir = voices_dir(app)?;
|
||||
ensure_storage_dir(&dir)?;
|
||||
let file_path = dir.join(&file_name);
|
||||
let file_created = !file_path.exists();
|
||||
if file_created {
|
||||
atomic_write_json_restricted(&file_path, &canonical)
|
||||
.map_err(|error| format!("could not save imported voice audio: {error}"))?;
|
||||
} else {
|
||||
if !is_regular_file_without_symlink(&file_path) {
|
||||
return Err("Imported voice storage contains an unsafe file entry".to_string());
|
||||
}
|
||||
let existing = fs::read(&file_path)
|
||||
.map_err(|error| format!("could not verify imported voice audio: {error}"))?;
|
||||
if hex::encode(Sha256::digest(&existing)) != hash {
|
||||
return Err("Imported voice storage contains mismatched audio data".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut imported = ImportedVoice {
|
||||
key,
|
||||
display_name,
|
||||
content_hash: hash,
|
||||
file_name,
|
||||
};
|
||||
let mut voices = load_registry(app)?;
|
||||
if let Some(existing) = voices
|
||||
.iter()
|
||||
.find(|voice| voice.content_hash == imported.content_hash)
|
||||
{
|
||||
imported = existing.clone();
|
||||
} else {
|
||||
voices.push(imported.clone());
|
||||
}
|
||||
if let Err(error) = save_registry(app, &voices) {
|
||||
if file_created {
|
||||
let _ = fs::remove_file(&file_path);
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
Ok(imported)
|
||||
}
|
||||
|
||||
pub fn delete(app: &AppHandle, key: &str) -> Result<(), String> {
|
||||
let mut voices = load_registry(app)?;
|
||||
let index = voices
|
||||
.iter()
|
||||
.position(|voice| voice.key == key)
|
||||
.ok_or_else(|| format!("Unknown imported voice: {key}"))?;
|
||||
let previous_voices = voices.clone();
|
||||
let removed = voices.remove(index);
|
||||
save_registry(app, &voices)?;
|
||||
let path = voices_dir(app)?.join(removed.file_name);
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => {
|
||||
save_registry(app, &previous_voices).map_err(|rollback_error| {
|
||||
format!(
|
||||
"Imported voice audio could not be deleted ({error}), and its registry entry \
|
||||
could not be restored ({rollback_error})"
|
||||
)
|
||||
})?;
|
||||
Err(format!(
|
||||
"Imported voice audio could not be deleted: {error}"
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_hash(hash: &str) -> bool {
|
||||
hash.len() == 64 && hash.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn is_regular_file_without_symlink(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path)
|
||||
.is_ok_and(|metadata| metadata.file_type().is_file() && !metadata.file_type().is_symlink())
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DecodedWav {
|
||||
sample_rate: u32,
|
||||
samples: Vec<f32>,
|
||||
}
|
||||
|
||||
fn decode_wav(bytes: &[u8]) -> Result<DecodedWav, String> {
|
||||
if bytes.len() < 12 || &bytes[..4] != b"RIFF" || &bytes[8..12] != b"WAVE" {
|
||||
return Err("Selected file is not a valid RIFF/WAVE file".to_string());
|
||||
}
|
||||
let mut offset = 12usize;
|
||||
let mut format = None;
|
||||
let mut data = None;
|
||||
while offset.checked_add(8).is_some_and(|end| end <= bytes.len()) {
|
||||
let id = &bytes[offset..offset + 4];
|
||||
let size =
|
||||
u32::from_le_bytes(bytes[offset + 4..offset + 8].try_into().unwrap_or([0; 4])) as usize;
|
||||
let start = offset + 8;
|
||||
let end = start.checked_add(size).ok_or("WAV chunk size overflow")?;
|
||||
if end > bytes.len() {
|
||||
return Err("Selected WAV contains a truncated chunk".to_string());
|
||||
}
|
||||
if id == b"fmt " {
|
||||
format = Some(&bytes[start..end]);
|
||||
} else if id == b"data" {
|
||||
data = Some(&bytes[start..end]);
|
||||
}
|
||||
offset = end + (size & 1);
|
||||
}
|
||||
let format = format.ok_or("Selected WAV has no format chunk")?;
|
||||
let data = data.ok_or("Selected WAV has no audio data")?;
|
||||
if format.len() < 16 {
|
||||
return Err("Selected WAV has an invalid format chunk".to_string());
|
||||
}
|
||||
let encoding = u16::from_le_bytes(format[0..2].try_into().unwrap_or([0; 2]));
|
||||
let encoding = if encoding == 0xfffe && format.len() >= 40 {
|
||||
u16::from_le_bytes(format[24..26].try_into().unwrap_or([0; 2]))
|
||||
} else {
|
||||
encoding
|
||||
};
|
||||
let channels = u16::from_le_bytes(format[2..4].try_into().unwrap_or([0; 2]));
|
||||
let sample_rate = u32::from_le_bytes(format[4..8].try_into().unwrap_or([0; 4]));
|
||||
let block_align = u16::from_le_bytes(format[12..14].try_into().unwrap_or([0; 2])) as usize;
|
||||
let bits = u16::from_le_bytes(format[14..16].try_into().unwrap_or([0; 2]));
|
||||
if channels != 1 {
|
||||
return Err("Voice WAV must be mono".to_string());
|
||||
}
|
||||
if !(MIN_SAMPLE_RATE..=MAX_SAMPLE_RATE).contains(&sample_rate) {
|
||||
return Err("Voice WAV sample rate must be between 8 and 96 kHz".to_string());
|
||||
}
|
||||
let bytes_per_sample = usize::from(bits.div_ceil(8));
|
||||
if block_align != bytes_per_sample || block_align == 0 || data.len() % block_align != 0 {
|
||||
return Err("Voice WAV has invalid sample alignment".to_string());
|
||||
}
|
||||
if !matches!((encoding, bits), (1, 8 | 16 | 24 | 32) | (3, 32)) {
|
||||
return Err("Voice WAV must contain PCM or 32-bit float audio".to_string());
|
||||
}
|
||||
let frames = data.len() / block_align;
|
||||
let duration = frames as f64 / f64::from(sample_rate);
|
||||
if !(MIN_DURATION_SECONDS..=MAX_DURATION_SECONDS).contains(&duration) {
|
||||
return Err("Voice WAV must be between 2 and 30 seconds long".to_string());
|
||||
}
|
||||
|
||||
let mut samples = Vec::with_capacity(frames);
|
||||
for chunk in data.chunks_exact(block_align) {
|
||||
let sample = match (encoding, bits) {
|
||||
(1, 8) => (f32::from(chunk[0]) - 128.0) / 128.0,
|
||||
(1, 16) => f32::from(i16::from_le_bytes([chunk[0], chunk[1]])) / 32768.0,
|
||||
(1, 24) => {
|
||||
let raw = i32::from_le_bytes([
|
||||
chunk[0],
|
||||
chunk[1],
|
||||
chunk[2],
|
||||
if chunk[2] & 0x80 == 0 { 0 } else { 0xff },
|
||||
]);
|
||||
raw as f32 / 8_388_608.0
|
||||
}
|
||||
(1, 32) => {
|
||||
i32::from_le_bytes(chunk.try_into().map_err(|_| "invalid PCM sample")?) as f32
|
||||
/ 2_147_483_648.0
|
||||
}
|
||||
(3, 32) => f32::from_le_bytes(
|
||||
chunk
|
||||
.try_into()
|
||||
.map_err(|_| "invalid floating-point sample")?,
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
if !sample.is_finite() {
|
||||
return Err("Voice WAV contains non-finite samples".to_string());
|
||||
}
|
||||
samples.push(sample.clamp(-1.0, 1.0));
|
||||
}
|
||||
let peak = samples
|
||||
.iter()
|
||||
.fold(0.0_f32, |peak, sample| peak.max(sample.abs()));
|
||||
let rms =
|
||||
(samples.iter().map(|sample| sample * sample).sum::<f32>() / samples.len() as f32).sqrt();
|
||||
if peak < 0.001 || rms < 0.0001 {
|
||||
return Err("Voice WAV is silent or too quiet to clone".to_string());
|
||||
}
|
||||
Ok(DecodedWav {
|
||||
sample_rate,
|
||||
samples,
|
||||
})
|
||||
}
|
||||
|
||||
fn resample_linear(samples: &[f32], source_rate: u32) -> Vec<f32> {
|
||||
if source_rate == CANONICAL_SAMPLE_RATE {
|
||||
return samples.to_vec();
|
||||
}
|
||||
let output_len = ((samples.len() as u64 * u64::from(CANONICAL_SAMPLE_RATE)
|
||||
+ u64::from(source_rate) / 2)
|
||||
/ u64::from(source_rate)) as usize;
|
||||
(0..output_len)
|
||||
.map(|index| {
|
||||
let source = index as f64 * f64::from(source_rate) / f64::from(CANONICAL_SAMPLE_RATE);
|
||||
let left = source.floor() as usize;
|
||||
let fraction = (source - left as f64) as f32;
|
||||
let a = samples[left.min(samples.len() - 1)];
|
||||
let b = samples[(left + 1).min(samples.len() - 1)];
|
||||
a + (b - a) * fraction
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn encode_pcm16_wav(samples: &[f32], sample_rate: u32) -> Vec<u8> {
|
||||
let data_len = (samples.len() * 2) as u32;
|
||||
let mut bytes = Vec::with_capacity(44 + data_len as usize);
|
||||
bytes.extend_from_slice(b"RIFF");
|
||||
bytes.extend_from_slice(&(36 + data_len).to_le_bytes());
|
||||
bytes.extend_from_slice(b"WAVEfmt ");
|
||||
bytes.extend_from_slice(&16_u32.to_le_bytes());
|
||||
bytes.extend_from_slice(&1_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&1_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&sample_rate.to_le_bytes());
|
||||
bytes.extend_from_slice(&(sample_rate * 2).to_le_bytes());
|
||||
bytes.extend_from_slice(&2_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(&16_u16.to_le_bytes());
|
||||
bytes.extend_from_slice(b"data");
|
||||
bytes.extend_from_slice(&data_len.to_le_bytes());
|
||||
for sample in samples {
|
||||
let value = (sample.clamp(-1.0, 1.0) * f32::from(i16::MAX)).round() as i16;
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
bytes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(sample_rate: u32, seconds: usize, amplitude: f32) -> Vec<u8> {
|
||||
let samples = (0..sample_rate as usize * seconds)
|
||||
.map(|index| {
|
||||
amplitude
|
||||
* (std::f32::consts::TAU * 220.0 * index as f32 / sample_rate as f32).sin()
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
encode_pcm16_wav(&samples, sample_rate)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_and_canonicalizes_supported_wav() {
|
||||
let decoded = decode_wav(&fixture(44_100, 2, 0.5)).expect("valid WAV");
|
||||
let canonical = resample_linear(&decoded.samples, decoded.sample_rate);
|
||||
assert_eq!(canonical.len(), 64_000);
|
||||
let encoded = encode_pcm16_wav(&canonical, CANONICAL_SAMPLE_RATE);
|
||||
let reparsed = decode_wav(&encoded).expect("canonical WAV");
|
||||
assert_eq!(reparsed.sample_rate, 32_000);
|
||||
assert_eq!(reparsed.samples.len(), 64_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_silent_and_short_wav() {
|
||||
assert!(decode_wav(&fixture(32_000, 2, 0.0))
|
||||
.expect_err("silence rejected")
|
||||
.contains("silent"));
|
||||
assert!(decode_wav(&fixture(32_000, 1, 0.5))
|
||||
.expect_err("short rejected")
|
||||
.contains("between 2 and 30"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_stereo_out_of_range_and_overlong_wav() {
|
||||
let mut stereo_header = fixture(32_000, 2, 0.5);
|
||||
stereo_header[22..24].copy_from_slice(&2_u16.to_le_bytes());
|
||||
assert!(decode_wav(&stereo_header)
|
||||
.expect_err("stereo rejected")
|
||||
.contains("mono"));
|
||||
|
||||
let mut low_rate_header = fixture(32_000, 2, 0.5);
|
||||
low_rate_header[24..28].copy_from_slice(&4_000_u32.to_le_bytes());
|
||||
assert!(decode_wav(&low_rate_header)
|
||||
.expect_err("low sample rate rejected")
|
||||
.contains("between 8 and 96"));
|
||||
|
||||
assert!(decode_wav(&fixture(8_000, 31, 0.5))
|
||||
.expect_err("overlong rejected")
|
||||
.contains("between 2 and 30"));
|
||||
}
|
||||
library(app)?.delete(key)
|
||||
}
|
||||
|
||||
@@ -151,6 +151,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. */
|
||||
@@ -9860,6 +9862,12 @@ 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}`,
|
||||
|
||||
@@ -129,6 +129,7 @@ test.describe("Pocket voice settings", () => {
|
||||
"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();
|
||||
@@ -137,22 +138,75 @@ test.describe("Pocket voice settings", () => {
|
||||
"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", "delete_pocket_voice"].includes(
|
||||
entry.command,
|
||||
),
|
||||
[
|
||||
"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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -137,6 +137,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. */
|
||||
|
||||
Reference in New Issue
Block a user