From 70fdeadbfed2855807f04414c86b19d7f796ee95 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 05:11:50 -0400 Subject: [PATCH 1/8] feat(mobile): add Pocket TTS Signed-off-by: John Tennant --- .gitignore | 1 + Cargo.lock | 7 + Cargo.toml | 1 + crates/buzz-voice-mobile/Cargo.toml | 19 + .../include/buzz_voice_mobile.h | 26 ++ crates/buzz-voice-mobile/src/lib.rs | 226 +++++++++++ mobile/android/app/build.gradle.kts | 16 + .../android/app/src/main/AndroidManifest.xml | 2 + .../xyz/block/buzz/mobile/MainActivity.kt | 16 + .../xyz/block/buzz/mobile/VoiceAudioOutput.kt | 240 ++++++++++++ .../app/src/main/res/xml/backup_rules.xml | 4 + .../main/res/xml/data_extraction_rules.xml | 9 + .../block/buzz/mobile/VoiceAudioOutputTest.kt | 28 ++ .../pocket_voice_integration_test.dart | 114 ++++++ mobile/ios/Flutter/Debug.xcconfig | 1 + mobile/ios/Flutter/Release.xcconfig | 1 + mobile/ios/Flutter/Voice.xcconfig | 2 + mobile/ios/Podfile.lock | 6 + mobile/ios/Runner.xcodeproj/project.pbxproj | 21 ++ mobile/ios/Runner/AppDelegate.swift | 4 + mobile/ios/Runner/Runner-Bridging-Header.h | 1 + mobile/ios/Runner/VoiceAudioOutput.swift | 191 ++++++++++ mobile/ios/RunnerTests/RunnerTests.swift | 82 ++++ .../channels/channel_detail_page.dart | 23 ++ .../channels/pocket_voice_button.dart | 56 +++ .../channels/pocket_voice_conversation.dart | 59 +++ .../channels/send_message_provider.dart | 12 +- .../features/channels/thread_detail_page.dart | 42 ++- .../lib/features/settings/settings_page.dart | 4 + .../settings/settings_page/voice_section.dart | 68 ++++ .../shared/voice/pocket_model_downloader.dart | 357 ++++++++++++++++++ .../shared/voice/pocket_model_manifest.dart | 103 +++++ .../shared/voice/pocket_model_provider.dart | 128 +++++++ .../shared/voice/pocket_voice_controller.dart | 182 +++++++++ mobile/lib/shared/voice/pocket_voice_ffi.dart | 130 +++++++ .../lib/shared/voice/pocket_voice_worker.dart | 175 +++++++++ .../lib/shared/voice/voice_audio_output.dart | 40 ++ mobile/pubspec.lock | 41 +- mobile/pubspec.yaml | 3 + .../pocket_voice_conversation_test.dart | 101 +++++ .../channels/send_message_provider_test.dart | 24 ++ .../goldens/pocket_voice_settings.png | Bin 0 -> 52489 bytes .../settings/pocket_voice_settings_test.dart | 67 ++++ .../voice/pocket_model_downloader_test.dart | 169 +++++++++ scripts/mobile-voice-native.sh | 218 +++++++++++ 45 files changed, 3017 insertions(+), 3 deletions(-) create mode 100644 crates/buzz-voice-mobile/Cargo.toml create mode 100644 crates/buzz-voice-mobile/include/buzz_voice_mobile.h create mode 100644 crates/buzz-voice-mobile/src/lib.rs create mode 100644 mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt create mode 100644 mobile/android/app/src/main/res/xml/backup_rules.xml create mode 100644 mobile/android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/VoiceAudioOutputTest.kt create mode 100644 mobile/integration_test/pocket_voice_integration_test.dart create mode 100644 mobile/ios/Flutter/Voice.xcconfig create mode 100644 mobile/ios/Runner/VoiceAudioOutput.swift create mode 100644 mobile/lib/features/channels/pocket_voice_button.dart create mode 100644 mobile/lib/features/channels/pocket_voice_conversation.dart create mode 100644 mobile/lib/features/settings/settings_page/voice_section.dart create mode 100644 mobile/lib/shared/voice/pocket_model_downloader.dart create mode 100644 mobile/lib/shared/voice/pocket_model_manifest.dart create mode 100644 mobile/lib/shared/voice/pocket_model_provider.dart create mode 100644 mobile/lib/shared/voice/pocket_voice_controller.dart create mode 100644 mobile/lib/shared/voice/pocket_voice_ffi.dart create mode 100644 mobile/lib/shared/voice/pocket_voice_worker.dart create mode 100644 mobile/lib/shared/voice/voice_audio_output.dart create mode 100644 mobile/test/features/channels/pocket_voice_conversation_test.dart create mode 100644 mobile/test/features/settings/goldens/pocket_voice_settings.png create mode 100644 mobile/test/features/settings/pocket_voice_settings_test.dart create mode 100644 mobile/test/shared/voice/pocket_model_downloader_test.dart create mode 100755 scripts/mobile-voice-native.sh diff --git a/.gitignore b/.gitignore index 65ddcaf1c4..3ef0226174 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ /target/ /dist/ /admin-web/dist/ +/mobile/.generated/voice/ # lefthook-generated hook scripts (machine-specific) .hooks/ diff --git a/Cargo.lock b/Cargo.lock index d3ca5d916e..5f2f328635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1302,6 +1302,13 @@ dependencies = [ "tokenizers", ] +[[package]] +name = "buzz-voice-mobile" +version = "0.1.0" +dependencies = [ + "buzz-voice", +] + [[package]] name = "buzz-workflow" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f7bbad294b..c916e09040 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "crates/buzz-relay-mesh", "crates/buzz-dev-mcp", "crates/buzz-voice", + "crates/buzz-voice-mobile", "examples/countdown-bot", ] exclude = ["desktop/src-tauri", "crates/sherpa-onnx-sys"] diff --git a/crates/buzz-voice-mobile/Cargo.toml b/crates/buzz-voice-mobile/Cargo.toml new file mode 100644 index 0000000000..cfb5857095 --- /dev/null +++ b/crates/buzz-voice-mobile/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "buzz-voice-mobile" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Minimal mobile C ABI for Buzz Pocket TTS" + +[lib] +crate-type = ["rlib", "staticlib"] + +[features] +default = ["static"] +static = ["buzz-voice/static"] +shared = ["buzz-voice/shared"] + +[dependencies] +buzz-voice = { path = "../buzz-voice", default-features = false } diff --git a/crates/buzz-voice-mobile/include/buzz_voice_mobile.h b/crates/buzz-voice-mobile/include/buzz_voice_mobile.h new file mode 100644 index 0000000000..fb7a09a86c --- /dev/null +++ b/crates/buzz-voice-mobile/include/buzz_voice_mobile.h @@ -0,0 +1,26 @@ +#ifndef BUZZ_VOICE_MOBILE_H +#define BUZZ_VOICE_MOBILE_H + +#include +#include + +typedef struct BuzzVoiceEngineResult { + void *engine; + char *error; +} BuzzVoiceEngineResult; + +typedef struct BuzzVoicePcm { + int16_t *samples; + size_t len; + uint32_t sample_rate; + char *error; +} BuzzVoicePcm; + +BuzzVoiceEngineResult buzz_voice_engine_create(const char *model_dir); +BuzzVoicePcm buzz_voice_engine_synthesize(void *engine, const char *text); +void buzz_voice_engine_cancel(void *engine); +void buzz_voice_engine_destroy(void *engine); +void buzz_voice_pcm_free(BuzzVoicePcm pcm); +void buzz_voice_string_free(char *value); + +#endif diff --git a/crates/buzz-voice-mobile/src/lib.rs b/crates/buzz-voice-mobile/src/lib.rs new file mode 100644 index 0000000000..b94358d4b4 --- /dev/null +++ b/crates/buzz-voice-mobile/src/lib.rs @@ -0,0 +1,226 @@ +//! Minimal C ABI for running [`buzz_voice::pocket`] on mobile. + +#![deny(unsafe_op_in_unsafe_fn)] + +use std::ffi::{c_char, c_void, CStr, CString}; +use std::ptr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; + +use buzz_voice::pocket::{ + load_text_to_speech, load_voice_style, PocketTts, VoiceStyle, DEFAULT_VOICE, SAMPLE_RATE, + VOICE_FILE_EXT, +}; + +struct Engine { + tts: PocketTts, + voice: VoiceStyle, + cancelled: Arc, +} + +/// Result returned by [`buzz_voice_engine_create`]. +#[repr(C)] +pub struct BuzzVoiceEngineResult { + /// Opaque engine handle, or null when creation failed. + pub engine: *mut c_void, + /// Owned UTF-8 error string, or null on success. + pub error: *mut c_char, +} + +/// Owned 24 kHz mono signed-16-bit PCM returned by +/// [`buzz_voice_engine_synthesize`]. +#[repr(C)] +pub struct BuzzVoicePcm { + /// Owned sample buffer, or null when synthesis failed. + pub samples: *mut i16, + /// Number of samples in `samples`. + pub len: usize, + /// Sample rate in Hz. + pub sample_rate: u32, + /// Owned UTF-8 error string, or null on success. + pub error: *mut c_char, +} + +fn owned_error(message: impl Into) -> *mut c_char { + let message = message.into().replace('\0', " "); + CString::new(message) + .map(CString::into_raw) + .unwrap_or(ptr::null_mut()) +} + +fn engine_error(message: impl Into) -> BuzzVoiceEngineResult { + BuzzVoiceEngineResult { + engine: ptr::null_mut(), + error: owned_error(message), + } +} + +fn pcm_error(message: impl Into) -> BuzzVoicePcm { + BuzzVoicePcm { + samples: ptr::null_mut(), + len: 0, + sample_rate: SAMPLE_RATE, + error: owned_error(message), + } +} + +fn input_string(value: *const c_char, label: &str) -> Result { + if value.is_null() { + return Err(format!("{label} is null")); + } + // SAFETY: The C ABI requires a non-null, NUL-terminated string that stays + // alive for the duration of this call. We copy it before returning. + let value = unsafe { CStr::from_ptr(value) }; + value + .to_str() + .map(str::to_owned) + .map_err(|error| format!("{label} is not UTF-8: {error}")) +} + +/// Create and retain one Pocket engine and its reference voice. +#[no_mangle] +pub extern "C" fn buzz_voice_engine_create(model_dir: *const c_char) -> BuzzVoiceEngineResult { + let model_dir = match input_string(model_dir, "model_dir") { + Ok(value) => value, + Err(error) => return engine_error(error), + }; + let voice_path = std::path::Path::new(&model_dir) + .join(DEFAULT_VOICE) + .with_extension(VOICE_FILE_EXT); + let tts = match load_text_to_speech(&model_dir) { + Ok(value) => value, + Err(error) => return engine_error(error), + }; + let voice = match load_voice_style(&voice_path) { + Ok(value) => value, + Err(error) => return engine_error(error), + }; + let engine = Box::new(Engine { + tts, + voice, + cancelled: Arc::new(AtomicBool::new(false)), + }); + BuzzVoiceEngineResult { + engine: Box::into_raw(engine).cast(), + error: ptr::null_mut(), + } +} + +/// Synthesize one utterance into owned signed-16-bit PCM. +#[no_mangle] +pub extern "C" fn buzz_voice_engine_synthesize( + engine: *mut c_void, + text: *const c_char, +) -> BuzzVoicePcm { + if engine.is_null() { + return pcm_error("engine is null"); + } + let text = match input_string(text, "text") { + Ok(value) => value, + Err(error) => return pcm_error(error), + }; + // SAFETY: A handle returned by `buzz_voice_engine_create` remains owned by + // the caller until `buzz_voice_engine_destroy`; synthesis and destroy must + // not overlap. Cancellation only touches the atomic flag. + let engine = unsafe { &*(engine.cast::()) }; + engine.cancelled.store(false, Ordering::Release); + let cancelled = Arc::clone(&engine.cancelled); + let samples = match engine + .tts + .synth_chunk_with_callback( + &text, + "en", + &engine.voice, + 1, + Some(move |_: &[f32], _| !cancelled.load(Ordering::Acquire)), + ) { + Ok(value) => value, + Err(error) => return pcm_error(error), + }; + if engine.cancelled.load(Ordering::Acquire) { + return pcm_error("synthesis cancelled"); + } + + let samples: Box<[i16]> = samples + .into_iter() + .map(|sample| { + let sample = if sample.is_finite() { sample } else { 0.0 }; + (sample.clamp(-1.0, 1.0) * i16::MAX as f32).round() as i16 + }) + .collect::>() + .into_boxed_slice(); + let len = samples.len(); + BuzzVoicePcm { + samples: Box::into_raw(samples).cast::(), + len, + sample_rate: SAMPLE_RATE, + error: ptr::null_mut(), + } +} + +/// Request cancellation of the current synthesis call. +#[no_mangle] +pub extern "C" fn buzz_voice_engine_cancel(engine: *mut c_void) { + if engine.is_null() { + return; + } + // SAFETY: See `buzz_voice_engine_synthesize`. The engine remains alive + // while this atomic cancellation request is issued. + let engine = unsafe { &*(engine.cast::()) }; + engine.cancelled.store(true, Ordering::Release); +} + +/// Destroy an engine after any synthesis call has returned. +#[no_mangle] +pub extern "C" fn buzz_voice_engine_destroy(engine: *mut c_void) { + if engine.is_null() { + return; + } + // SAFETY: The pointer came from `Box::into_raw` in create and is consumed + // exactly once by this function. + unsafe { drop(Box::from_raw(engine.cast::())) }; +} + +/// Release a PCM value returned by [`buzz_voice_engine_synthesize`]. +#[no_mangle] +pub extern "C" fn buzz_voice_pcm_free(pcm: BuzzVoicePcm) { + if !pcm.samples.is_null() { + // SAFETY: The allocation came from `Box<[i16]>` in synthesis and is + // consumed exactly once here with the same slice length. + let slice = ptr::slice_from_raw_parts_mut(pcm.samples, pcm.len); + unsafe { drop(Box::from_raw(slice)) }; + } + buzz_voice_string_free(pcm.error); +} + +/// Release an error string returned by this ABI. +#[no_mangle] +#[allow(clippy::not_unsafe_ptr_arg_deref)] +pub extern "C" fn buzz_voice_string_free(value: *mut c_char) { + if value.is_null() { + return; + } + // SAFETY: Error pointers are produced by `CString::into_raw` and consumed + // exactly once here. + unsafe { drop(CString::from_raw(value)) }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn null_inputs_return_owned_errors() { + let engine = buzz_voice_engine_create(ptr::null()); + assert!(engine.engine.is_null()); + assert!(!engine.error.is_null()); + buzz_voice_string_free(engine.error); + + let pcm = buzz_voice_engine_synthesize(ptr::null_mut(), ptr::null()); + assert!(pcm.samples.is_null()); + assert_eq!(pcm.len, 0); + assert_eq!(pcm.sample_rate, SAMPLE_RATE); + assert!(!pcm.error.is_null()); + buzz_voice_pcm_free(pcm); + } +} diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 501a31c46f..7d4d95ab9e 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -94,6 +94,8 @@ android { resValue("string", "app_name", "Buzz") } + sourceSets["main"].jniLibs.srcDir("../../.generated/voice/android/jniLibs") + signingConfigs { if (hasUploadSigning) { create("upload") { @@ -124,6 +126,20 @@ android { } } +val buildBuzzVoiceNative by tasks.registering(Exec::class) { + workingDir(rootProject.projectDir.parentFile.parentFile) + environment("ANDROID_NDK_HOME", android.ndkDirectory.absolutePath) + commandLine( + "/bin/bash", + "-c", + ". ./bin/activate-hermit && ./scripts/mobile-voice-native.sh build-android", + ) +} + +tasks.named("preBuild").configure { + dependsOn(buildBuzzVoiceNative) +} + dependencies { testImplementation(kotlin("test")) diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 17a16742af..b63e4d40ba 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + if (shouldStopPocketVoiceForAudioFocusChange(change)) { + stopAndNotify("interrupted") + } + } + private val noisyReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + stopAndNotify("routeLost") + } + } + + init { + channel.setMethodCallHandler(::handle) + applicationContext.registerReceiver( + noisyReceiver, + IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY), + ) + } + + fun background() { + mainHandler.post { + stop(notify = null) + channel.invokeMethod("backgrounded", null) + } + } + + fun dispose() { + stop(notify = null) + channel.setMethodCallHandler(null) + runCatching { applicationContext.unregisterReceiver(noisyReceiver) } + } + + private fun handle( + call: MethodCall, + result: MethodChannel.Result, + ) { + when (call.method) { + "play" -> { + val pcm = call.argument("pcm") + val sampleRate = call.argument("sampleRate") + if (pcm == null || sampleRate == null) { + result.error("invalid_arguments", "Expected PCM and sample rate.", null) + return + } + runCatching { play(pcm, sampleRate) } + .onSuccess { result.success(null) } + .onFailure { + result.error("playback_failed", it.message, null) + } + } + "stop" -> { + stop(notify = null) + result.success(null) + } + "availableCapacity" -> { + val path = call.arguments as? String + if (path == null) { + result.error("invalid_arguments", "Expected a storage path.", null) + } else { + runCatching { StatFs(path).availableBytes } + .onSuccess(result::success) + .onFailure { result.error("storage_failed", it.message, null) } + } + } + "excludeFromBackup" -> result.success(null) + else -> result.notImplemented() + } + } + + private fun play( + pcm: ByteArray, + sampleRate: Int, + ) { + stop(notify = null) + check(requestFocus()) { "Audio focus was denied." } + val nextTrack = + try { + val attributes = + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build() + val format = + AudioFormat.Builder() + .setEncoding(AudioFormat.ENCODING_PCM_16BIT) + .setSampleRate(sampleRate) + .setChannelMask(AudioFormat.CHANNEL_OUT_MONO) + .build() + val minimum = AudioTrack.getMinBufferSize( + sampleRate, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + ) + AudioTrack.Builder() + .setAudioAttributes(attributes) + .setAudioFormat(format) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(max(minimum, 32 * 1024)) + .build() + .also { + check(it.state == AudioTrack.STATE_INITIALIZED) { + "Unable to initialize voice audio." + } + } + } catch (error: Exception) { + abandonFocus() + throw error + } + track = nextTrack + val currentGeneration = generation.incrementAndGet() + nextTrack.play() + thread(name = "buzz-pocket-playback") { + var offset = 0 + while (offset < pcm.size && generation.get() == currentGeneration) { + val written = nextTrack.write( + pcm, + offset, + pcm.size - offset, + AudioTrack.WRITE_BLOCKING, + ) + if (written <= 0) break + offset += written + } + if (generation.get() == currentGeneration) { + nextTrack.stop() + nextTrack.release() + track = null + abandonFocus() + mainHandler.post { + channel.invokeMethod( + if (offset == pcm.size) "completed" else "error", + null, + ) + } + } + } + } + + private fun stopAndNotify(event: String) { + mainHandler.post { stop(notify = event) } + } + + private fun stop(notify: String?) { + if (track == null && !hasFocus) return + generation.incrementAndGet() + track?.run { + pause() + flush() + stop() + release() + } + track = null + abandonFocus() + if (notify != null) channel.invokeMethod(notify, null) + } + + private fun requestFocus(): Boolean { + val result = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val request = + AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ASSISTANCE_ACCESSIBILITY) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(), + ) + .setOnAudioFocusChangeListener(focusListener, mainHandler) + .build() + focusRequest = request + audioManager.requestAudioFocus(request) + } else { + @Suppress("DEPRECATION") + audioManager.requestAudioFocus( + focusListener, + AudioManager.STREAM_MUSIC, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK, + ) + } + hasFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED + return hasFocus + } + + private fun abandonFocus() { + if (!hasFocus) return + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + focusRequest?.let(audioManager::abandonAudioFocusRequest) + } else { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(focusListener) + } + focusRequest = null + hasFocus = false + } + + private companion object { + const val CHANNEL = "buzz/voice_audio" + } +} diff --git a/mobile/android/app/src/main/res/xml/backup_rules.xml b/mobile/android/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000000..188f46cf7a --- /dev/null +++ b/mobile/android/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/android/app/src/main/res/xml/data_extraction_rules.xml b/mobile/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 0000000000..263cda4139 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/VoiceAudioOutputTest.kt b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/VoiceAudioOutputTest.kt new file mode 100644 index 0000000000..f35963e01b --- /dev/null +++ b/mobile/android/app/src/test/kotlin/xyz/block/buzz/mobile/VoiceAudioOutputTest.kt @@ -0,0 +1,28 @@ +package xyz.block.buzz.mobile + +import android.media.AudioManager +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class VoiceAudioOutputTest { + @Test + fun `all focus losses stop Pocket voice`() { + assertTrue(shouldStopPocketVoiceForAudioFocusChange(AudioManager.AUDIOFOCUS_LOSS)) + assertTrue( + shouldStopPocketVoiceForAudioFocusChange( + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT, + ), + ) + assertTrue( + shouldStopPocketVoiceForAudioFocusChange( + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK, + ), + ) + } + + @Test + fun `focus gain does not interrupt Pocket voice`() { + assertFalse(shouldStopPocketVoiceForAudioFocusChange(AudioManager.AUDIOFOCUS_GAIN)) + } +} diff --git a/mobile/integration_test/pocket_voice_integration_test.dart b/mobile/integration_test/pocket_voice_integration_test.dart new file mode 100644 index 0000000000..49525693e3 --- /dev/null +++ b/mobile/integration_test/pocket_voice_integration_test.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:buzz/shared/voice/pocket_model_downloader.dart'; +import 'package:buzz/shared/voice/pocket_voice_worker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'Pocket worker synthesizes valid PCM and cancels promptly', + (tester) async { + const configuredModelPath = String.fromEnvironment( + 'BUZZ_POCKET_MODEL_DIR', + ); + const downloadModel = bool.fromEnvironment('BUZZ_POCKET_DOWNLOAD'); + var modelPath = configuredModelPath; + Duration? downloadTime; + if (modelPath.isEmpty && downloadModel) { + final clock = Stopwatch()..start(); + final downloader = PocketModelDownloader(); + final directory = await downloader.install((downloaded, total, file) { + if (downloaded == total) { + debugPrint('Pocket model download complete: $file ($total bytes)'); + } + }); + clock.stop(); + downloadTime = clock.elapsed; + modelPath = directory.path; + expect(await downloader.verify(directory, hashContents: true), isTrue); + } + if (modelPath.isEmpty) { + fail( + 'Pass --dart-define=BUZZ_POCKET_MODEL_DIR= or ' + '--dart-define=BUZZ_POCKET_DOWNLOAD=true.', + ); + } + + final worker = PocketVoiceWorker(); + await worker.start(modelPath); + addTearDown(worker.dispose); + + final firstClock = Stopwatch()..start(); + worker.synthesize(1, 'Pocket voice is running on this mobile device.'); + final first = await worker.responses + .where((response) => response is PocketWorkerAudio) + .cast() + .first + .timeout(const Duration(seconds: 30)); + firstClock.stop(); + final bytes = first.data.materialize().asUint8List(); + _expectValidPcm(bytes, first.sampleRate); + final audioSeconds = bytes.length / 2 / first.sampleRate; + final rtf = + first.synthesisTime.inMicroseconds / + Duration.microsecondsPerSecond / + audioSeconds; + + final warmClock = Stopwatch()..start(); + worker.synthesize(2, 'The engine stays warm for the next response.'); + final warm = await worker.responses + .where((response) => response is PocketWorkerAudio) + .cast() + .where((audio) => audio.generation == 2) + .first + .timeout(const Duration(seconds: 30)); + warmClock.stop(); + _expectValidPcm(warm.data.materialize().asUint8List(), warm.sampleRate); + + final cancelClock = Stopwatch()..start(); + worker.synthesize( + 3, + List.filled( + 20, + 'This deliberately long sentence gives cancellation time to interrupt.', + ).join(' '), + ); + await Future.delayed(const Duration(milliseconds: 50)); + worker.cancel(); + final cancelled = await worker.responses + .where((response) => response is PocketWorkerFailure) + .cast() + .where((failure) => failure.generation == 3) + .first + .timeout(const Duration(seconds: 10)); + cancelClock.stop(); + expect(cancelled.message, contains('cancelled')); + + // Emitted in a stable shape so simulator/device runs are easy to compare. + debugPrint( + 'BUZZ_POCKET_METRICS ' + 'first_pcm_ms=${firstClock.elapsedMilliseconds} ' + 'synthesis_ms=${first.synthesisTime.inMilliseconds} ' + 'audio_s=${audioSeconds.toStringAsFixed(3)} ' + 'rtf=${rtf.toStringAsFixed(3)} ' + 'warm_pcm_ms=${warmClock.elapsedMilliseconds} ' + 'cancel_ms=${cancelClock.elapsedMilliseconds} ' + 'download_ms=${downloadTime?.inMilliseconds ?? 0} ' + 'rss_bytes=${ProcessInfo.currentRss} ' + 'peak_rss_bytes=${ProcessInfo.maxRss}', + ); + }, + timeout: const Timeout(Duration(minutes: 20)), + ); +} + +void _expectValidPcm(Uint8List bytes, int sampleRate) { + expect(sampleRate, 24000); + expect(bytes.length, greaterThan(4800)); + expect(bytes.length % 2, 0); + expect(bytes.any((byte) => byte != 0), isTrue); +} diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 1f35905051..7ea6b87f99 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -19,3 +19,4 @@ APP_DISPLAY_NAME = Buzz // device signing beats the worktree default while unset variables still // fall through to the worktree values. #include? "AppOverrides.xcconfig" +#include "Voice.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index d287c5fb43..e16556116c 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -9,3 +9,4 @@ APP_DISPLAY_NAME = Buzz CODE_SIGN_STYLE = Automatic CODE_SIGN_IDENTITY = iPhone Developer #include? "AppOverrides.xcconfig" +#include "Voice.xcconfig" diff --git a/mobile/ios/Flutter/Voice.xcconfig b/mobile/ios/Flutter/Voice.xcconfig new file mode 100644 index 0000000000..8707244f97 --- /dev/null +++ b/mobile/ios/Flutter/Voice.xcconfig @@ -0,0 +1,2 @@ +BUZZ_VOICE_LIB_DIR = $(SRCROOT)/../.generated/voice/ios/$(PLATFORM_NAME)-arm64 +OTHER_LDFLAGS = $(inherited) -Wl,-force_load,$(BUZZ_VOICE_LIB_DIR)/libbuzz_voice_mobile.a -lc++ -framework Accelerate -framework CoreML -framework Foundation -framework Metal -framework QuartzCore -framework Security diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index ba8c828d25..8ef612f5ff 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -15,6 +15,8 @@ PODS: - FlutterMacOS - image_picker_ios (0.0.1): - Flutter + - integration_test (0.0.1): + - Flutter - mobile_scanner (7.0.0): - Flutter - FlutterMacOS @@ -40,6 +42,7 @@ DEPENDENCIES: - Flutter (from `Flutter`) - flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) - mobile_scanner (from `.symlinks/plugins/mobile_scanner/darwin`) - open_filex (from `.symlinks/plugins/open_filex/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -64,6 +67,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" mobile_scanner: :path: ".symlinks/plugins/mobile_scanner/darwin" open_filex: @@ -86,6 +91,7 @@ SPEC CHECKSUMS: Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326 + integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e mobile_scanner: 9157936403f5a0644ca3779a38ff8404c5434a93 open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1 package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 46c61ce2fc..8a92f373dc 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 42C129326CE4E1B8E617B9CD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + A70D8C8D31879E5200B02246 /* VoiceAudioOutput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A70D8C8C31879E5200B02246 /* VoiceAudioOutput.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; @@ -72,6 +73,7 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A70D8C8C31879E5200B02246 /* VoiceAudioOutput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceAudioOutput.swift; sourceTree = ""; }; CD6B899582D0416ADBD8A68F /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -171,6 +173,7 @@ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 331C809A294A618700263BE5 /* MediaSanitizer.swift */, 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, + A70D8C8C31879E5200B02246 /* VoiceAudioOutput.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; @@ -213,6 +216,7 @@ buildPhases = ( 1D327B920BBA7EF4545E2A92 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, + A70D8C8E31879E5200B02246 /* Build Pocket Voice */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, @@ -367,6 +371,22 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + A70D8C8E31879E5200B02246 /* Build Pocket Voice */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Build Pocket Voice"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "cd \"$SRCROOT/../..\"\n. ./bin/activate-hermit\n./scripts/mobile-voice-native.sh build-ios-current\n"; + showEnvVarsInLog = 0; + }; E0B5862D106D142B580309AF /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -403,6 +423,7 @@ 331C809B294A63AB00263BE5 /* MediaSanitizer.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, + A70D8C8D31879E5200B02246 /* VoiceAudioOutput.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index 5edb4b6aaf..47e3b06c5f 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -7,6 +7,7 @@ import UserNotifications @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { private var mediaUploadChannel: FlutterMethodChannel? private var qrScannerChannel: FlutterMethodChannel? + private var voiceAudioOutput: VoiceAudioOutput? override func application( _ application: UIApplication, @@ -32,6 +33,9 @@ import UserNotifications qrScannerChannel?.setMethodCallHandler { call, result in Self.handleQrScannerMethodCall(call, result: result) } + voiceAudioOutput = VoiceAudioOutput( + messenger: engineBridge.applicationRegistrar.messenger() + ) } private static func handleQrScannerMethodCall( diff --git a/mobile/ios/Runner/Runner-Bridging-Header.h b/mobile/ios/Runner/Runner-Bridging-Header.h index 308a2a560b..b421e7bbb2 100644 --- a/mobile/ios/Runner/Runner-Bridging-Header.h +++ b/mobile/ios/Runner/Runner-Bridging-Header.h @@ -1 +1,2 @@ #import "GeneratedPluginRegistrant.h" +#import "../../../crates/buzz-voice-mobile/include/buzz_voice_mobile.h" diff --git a/mobile/ios/Runner/VoiceAudioOutput.swift b/mobile/ios/Runner/VoiceAudioOutput.swift new file mode 100644 index 0000000000..ebd188613e --- /dev/null +++ b/mobile/ios/Runner/VoiceAudioOutput.swift @@ -0,0 +1,191 @@ +import AVFoundation +import Flutter +import UIKit + +final class VoiceAudioOutput: NSObject, AVAudioPlayerDelegate { + private let channel: FlutterMethodChannel + private var player: AVAudioPlayer? + private var observers: [NSObjectProtocol] = [] + + init(messenger: FlutterBinaryMessenger) { + channel = FlutterMethodChannel( + name: "buzz/voice_audio", + binaryMessenger: messenger + ) + super.init() + channel.setMethodCallHandler { [weak self] call, result in + self?.handle(call, result: result) + } + observeAudioLifecycle() + } + + deinit { + for observer in observers { + NotificationCenter.default.removeObserver(observer) + } + } + + private func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "play": + guard + let arguments = call.arguments as? [String: Any], + let typedData = arguments["pcm"] as? FlutterStandardTypedData, + let sampleRate = arguments["sampleRate"] as? Int + else { + result(FlutterError(code: "invalid_arguments", message: "Expected PCM and sample rate.", details: nil)) + return + } + do { + try play(pcm: typedData.data, sampleRate: sampleRate) + result(nil) + } catch { + result(FlutterError(code: "playback_failed", message: error.localizedDescription, details: nil)) + } + case "stop": + stop(deactivate: true) + result(nil) + case "availableCapacity": + guard let path = call.arguments as? String else { + result(FlutterError(code: "invalid_arguments", message: "Expected a storage path.", details: nil)) + return + } + do { + let values = try URL(fileURLWithPath: path).resourceValues( + forKeys: [.volumeAvailableCapacityForImportantUsageKey] + ) + result(values.volumeAvailableCapacityForImportantUsage ?? 0) + } catch { + result(FlutterError(code: "storage_failed", message: error.localizedDescription, details: nil)) + } + case "excludeFromBackup": + guard let path = call.arguments as? String else { + result(FlutterError(code: "invalid_arguments", message: "Expected a storage path.", details: nil)) + return + } + do { + var url = URL(fileURLWithPath: path) + var values = URLResourceValues() + values.isExcludedFromBackup = true + try url.setResourceValues(values) + result(nil) + } catch { + result(FlutterError(code: "storage_failed", message: error.localizedDescription, details: nil)) + } + default: + result(FlutterMethodNotImplemented) + } + } + + private func play(pcm: Data, sampleRate: Int) throws { + stop(deactivate: false) + let session = AVAudioSession.sharedInstance() + try Self.configureAudioSession(session) + try session.setActive(true) + let nextPlayer = try AVAudioPlayer(data: Self.waveData(pcm: pcm, sampleRate: sampleRate)) + nextPlayer.delegate = self + nextPlayer.prepareToPlay() + guard nextPlayer.play() else { + throw NSError( + domain: "BuzzVoice", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to start voice playback."] + ) + } + player = nextPlayer + } + + static func configureAudioSession(_ session: AVAudioSession) throws { + try session.setCategory(.playback, mode: .spokenAudio, options: [.duckOthers]) + } + + private func stop(deactivate: Bool) { + player?.stop() + player = nil + if deactivate { + try? AVAudioSession.sharedInstance().setActive( + false, + options: [.notifyOthersOnDeactivation] + ) + } + } + + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + self.player = nil + try? AVAudioSession.sharedInstance().setActive( + false, + options: [.notifyOthersOnDeactivation] + ) + channel.invokeMethod(flag ? "completed" : "error", arguments: nil) + } + + private func observeAudioLifecycle() { + let center = NotificationCenter.default + observers.append( + center.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard + let rawType = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt, + AVAudioSession.InterruptionType(rawValue: rawType) == .began + else { return } + self?.interrupt(with: "interrupted") + } + ) + observers.append( + center.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, + queue: .main + ) { [weak self] notification in + guard + let rawReason = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt, + AVAudioSession.RouteChangeReason(rawValue: rawReason) == .oldDeviceUnavailable + else { return } + self?.interrupt(with: "routeLost") + } + ) + observers.append( + center.addObserver( + forName: UIApplication.didEnterBackgroundNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.interrupt(with: "backgrounded", requirePlayback: false) + } + ) + } + + private func interrupt(with event: String, requirePlayback: Bool = true) { + if requirePlayback && player == nil { return } + stop(deactivate: true) + channel.invokeMethod(event, arguments: nil) + } + + private static func waveData(pcm: Data, sampleRate: Int) -> Data { + var output = Data() + output.append(contentsOf: "RIFF".utf8) + output.appendLittleEndian(UInt32(pcm.count + 36)) + output.append(contentsOf: "WAVEfmt ".utf8) + output.appendLittleEndian(UInt32(16)) + output.appendLittleEndian(UInt16(1)) + output.appendLittleEndian(UInt16(1)) + output.appendLittleEndian(UInt32(sampleRate)) + output.appendLittleEndian(UInt32(sampleRate * 2)) + output.appendLittleEndian(UInt16(2)) + output.appendLittleEndian(UInt16(16)) + output.append(contentsOf: "data".utf8) + output.appendLittleEndian(UInt32(pcm.count)) + output.append(pcm) + return output + } +} + +private extension Data { + mutating func appendLittleEndian(_ value: T) { + var littleEndian = value.littleEndian + Swift.withUnsafeBytes(of: &littleEndian) { append(contentsOf: $0) } + } +} diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index a174c78684..8a5bf834a5 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -1,4 +1,6 @@ import Flutter +import Darwin +import AVFoundation import UIKit import XCTest @@ -6,6 +8,65 @@ import XCTest class RunnerTests: XCTestCase { + func testPocketVoiceUsesSpokenPlaybackAudioSession() throws { + let session = AVAudioSession.sharedInstance() + try VoiceAudioOutput.configureAudioSession(session) + XCTAssertEqual(session.category, .playback) + XCTAssertEqual(session.mode, .spokenAudio) + XCTAssertTrue(session.categoryOptions.contains(.duckOthers)) + } + + func testPocketVoiceProducesValidPcmWhenModelIsAvailable() throws { + let modelPath = + ProcessInfo.processInfo.environment["BUZZ_POCKET_MODEL_DIR"] + ?? UserDefaults.standard.string(forKey: "BuzzPocketModelTestPath") + guard let modelPath, FileManager.default.fileExists(atPath: modelPath) else { + throw XCTSkip("Set BUZZ_POCKET_MODEL_DIR to run the Pocket TTS integration test.") + } + + let loadStart = ContinuousClock.now + let engineResult = modelPath.withCString(buzz_voice_engine_create) + let loadTime = loadStart.duration(to: .now) + if let error = engineResult.error { + defer { buzz_voice_string_free(error) } + XCTFail(String(cString: error)) + return + } + let engine = try XCTUnwrap(engineResult.engine) + defer { buzz_voice_engine_destroy(engine) } + + let prompt = "Pocket voice is running on this iOS simulator." + let firstStart = ContinuousClock.now + let first = prompt.withCString { buzz_voice_engine_synthesize(engine, $0) } + let firstTime = firstStart.duration(to: .now) + let firstData = try validatedPcmData(first) + buzz_voice_pcm_free(first) + + let warmStart = ContinuousClock.now + let warm = prompt.withCString { buzz_voice_engine_synthesize(engine, $0) } + let warmTime = warmStart.duration(to: .now) + let warmData = try validatedPcmData(warm) + buzz_voice_pcm_free(warm) + + var usage = rusage() + getrusage(RUSAGE_SELF, &usage) + let audioSeconds = Double(firstData.count) / 2 / 24_000 + let firstSeconds = durationSeconds(firstTime) + let metrics = + "BUZZ_POCKET_METRICS " + + "load_s=\(durationSeconds(loadTime)) " + + "first_pcm_s=\(firstSeconds) " + + "audio_s=\(audioSeconds) " + + "rtf=\(firstSeconds / audioSeconds) " + + "warm_pcm_s=\(durationSeconds(warmTime)) " + + "peak_rss_bytes=\(usage.ru_maxrss) " + + "repeat_pcm_equal=\(firstData == warmData)" + let attachment = XCTAttachment(string: metrics) + attachment.name = metrics + attachment.lifetime = .keepAlways + add(attachment) + } + func testDynamicIslandQrScannerRecognizesTallSafeAreas() { for safeAreaTopInset in [51, 59, 62] { XCTAssertTrue( @@ -226,6 +287,27 @@ class RunnerTests: XCTestCase { Bundle(for: RunnerTests.self).url(forResource: name, withExtension: fileExtension)) return try Data(contentsOf: url) } + + private func validatedPcmData(_ pcm: BuzzVoicePcm) throws -> Data { + if let error = pcm.error { + defer { buzz_voice_string_free(error) } + XCTFail(String(cString: error)) + return Data() + } + XCTAssertEqual(pcm.sample_rate, 24_000) + XCTAssertGreaterThan(pcm.len, 2_400) + let samples = try XCTUnwrap(pcm.samples) + let buffer = UnsafeBufferPointer(start: samples, count: pcm.len) + XCTAssertTrue(buffer.contains { $0 != 0 }) + XCTAssertGreaterThan(buffer.map { abs(Int32($0)) }.max() ?? 0, 100) + return Data(bytes: samples, count: pcm.len * MemoryLayout.size) + } + + private func durationSeconds(_ duration: Duration) -> Double { + let components = duration.components + return Double(components.seconds) + + Double(components.attoseconds) / 1_000_000_000_000_000_000 + } } private enum RelayImagePolicyError: Error { diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index f94226ac80..9987b8de01 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -13,6 +13,7 @@ import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/skeleton.dart'; +import '../../shared/voice/pocket_voice_controller.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/profile_provider.dart'; import '../profile/user_cache_provider.dart'; @@ -35,6 +36,8 @@ import 'members_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; import 'mentions/mention_candidates_provider.dart'; +import 'pocket_voice_button.dart'; +import 'pocket_voice_conversation.dart'; import 'read_state/deferred_read_state_update.dart'; import 'read_state/read_state_provider.dart'; import 'read_state/read_state_time.dart'; @@ -123,12 +126,31 @@ class ChannelDetailPage extends HookConsumerWidget { final detailsAsync = ref.watch(channelDetailsProvider(channel.id)); final channelsAsync = ref.watch(channelsProvider); final messagesState = ref.watch(channelMessagesProvider(channel.id)); + final membersState = ref.watch(channelMembersProvider(channel.id)); + final voiceConversation = useMemoized(PocketVoiceConversation.new); final sessionStatus = ref.watch(relaySessionProvider).status; final readState = ref.watch(readStateProvider); final currentPubkey = ref .watch(profileProvider) .whenData((value) => value?.pubkey) .value; + final voiceEventKey = + messagesState.value?.map((event) => event.id).join(',') ?? ''; + useEffect(() { + final events = messagesState.value; + if (events == null) return null; + final spoken = voiceConversation.update( + events: events, + members: membersState.value, + currentPubkey: currentPubkey, + ); + for (final event in spoken) { + ref + .read(pocketVoiceProvider.notifier) + .speak('channel:${channel.id}', event.content); + } + return null; + }, [voiceEventKey, membersState.value, currentPubkey, channel.id]); // Only show channel-level typing (exclude thread-scoped entries and self). final typingEntries = ref .watch(channelTypingProvider(channel.id)) @@ -254,6 +276,7 @@ class ChannelDetailPage extends HookConsumerWidget { ], ), actions: [ + PocketVoiceButton(conversationKey: 'channel:${channel.id}'), _MembersButton( channelId: resolvedChannel.id, channel: resolvedChannel, diff --git a/mobile/lib/features/channels/pocket_voice_button.dart b/mobile/lib/features/channels/pocket_voice_button.dart new file mode 100644 index 0000000000..dc8571cbe1 --- /dev/null +++ b/mobile/lib/features/channels/pocket_voice_button.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:lucide_icons_flutter/lucide_icons.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/voice/pocket_model_provider.dart'; +import '../../shared/voice/pocket_voice_controller.dart'; + +class PocketVoiceButton extends ConsumerWidget { + const PocketVoiceButton({super.key, required this.conversationKey}); + + final String conversationKey; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final voice = ref.watch(pocketVoiceProvider); + final model = ref.watch(pocketModelProvider); + final active = voice.enabled && voice.conversationKey == conversationKey; + + return IconButton( + color: active ? context.colors.primary : null, + tooltip: active ? 'Stop Pocket voice' : 'Start Pocket voice', + onPressed: () async { + if (active) { + await ref.read(pocketVoiceProvider.notifier).disable(); + return; + } + if (model.phase != PocketModelPhase.ready) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Download Pocket voice in Settings first.'), + ), + ); + } + return; + } + try { + await ref.read(pocketVoiceProvider.notifier).enable(conversationKey); + } catch (error) { + if (context.mounted) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); + } + } + }, + icon: voice.phase == PocketVoicePhase.loading && active + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(LucideIcons.audioLines, size: 21), + ); + } +} diff --git a/mobile/lib/features/channels/pocket_voice_conversation.dart b/mobile/lib/features/channels/pocket_voice_conversation.dart new file mode 100644 index 0000000000..a4867bbf32 --- /dev/null +++ b/mobile/lib/features/channels/pocket_voice_conversation.dart @@ -0,0 +1,59 @@ +import '../../shared/relay/relay.dart'; +import 'channel_management_provider.dart'; + +/// Selects newly-arrived bot messages for Pocket voice. +/// +/// Membership is authoritative: messages are never spoken until the channel's +/// member list has resolved, and history present when the view opens is only +/// used to establish the deduplication baseline. +class PocketVoiceConversation { + final Set _seen = {}; + bool _initialized = false; + + List update({ + required List events, + required List? members, + required String? currentPubkey, + String? threadHeadId, + }) { + final unseen = []; + for (final event in events) { + if (_seen.add(event.id)) unseen.add(event); + } + if (_seen.length > 1024) { + final retained = events.reversed + .take(512) + .map((event) => event.id) + .toSet(); + _seen + ..clear() + ..addAll(retained); + } + if (!_initialized) { + _initialized = true; + return const []; + } + if (members == null) return const []; + + final bots = { + for (final member in members) + if (member.isBot) member.pubkey.toLowerCase(), + }; + final self = currentPubkey?.toLowerCase(); + return [ + for (final event in unseen) + if (event.kind == EventKind.streamMessage && + event.pubkey.toLowerCase() != self && + bots.contains(event.pubkey.toLowerCase()) && + event.content.trim().length > 1 && + !event.content.trimLeft().startsWith('[System]') && + _belongsToConversation(event, threadHeadId)) + event, + ]; + } + + static bool _belongsToConversation(NostrEvent event, String? threadHeadId) { + final parentId = event.threadReference.parentId; + return threadHeadId == null ? parentId == null : parentId == threadHeadId; + } +} diff --git a/mobile/lib/features/channels/send_message_provider.dart b/mobile/lib/features/channels/send_message_provider.dart index 3659bda4bf..fa37d3cae8 100644 --- a/mobile/lib/features/channels/send_message_provider.dart +++ b/mobile/lib/features/channels/send_message_provider.dart @@ -1,6 +1,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/relay/relay.dart'; +import '../../shared/voice/pocket_voice_controller.dart'; import '../channels/channel_management_provider.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; @@ -15,6 +16,7 @@ class SendMessage { final void Function(String channelId, NostrEvent event) _addLocalMessage; final void Function(String channelId, String eventId) _completeLocalMessage; final void Function(String channelId, String eventId) _removeLocalMessage; + final Future Function()? _onBeforeSend; SendMessage({ required SignedEventRelay signedEventRelay, @@ -25,12 +27,14 @@ class SendMessage { required void Function(String channelId, String eventId) completeLocalMessage, required void Function(String channelId, String eventId) removeLocalMessage, + Future Function()? onBeforeSend, }) : _signedEventRelay = signedEventRelay, _fetchMembers = fetchMembers, _readUserCache = readUserCache, _addLocalMessage = addLocalMessage, _completeLocalMessage = completeLocalMessage, - _removeLocalMessage = removeLocalMessage; + _removeLocalMessage = removeLocalMessage, + _onBeforeSend = onBeforeSend; /// Send a text message to a channel. /// @@ -47,6 +51,7 @@ class SendMessage { List? mentionPubkeys, List> mediaTags = const [], }) async { + await _onBeforeSend?.call(); // Use explicitly passed pubkeys, or resolve @mentions against // channel members to avoid matching the wrong user. final resolvedMentions = @@ -179,5 +184,10 @@ final sendMessageProvider = Provider((ref) { removeLocalMessage: (channelId, eventId) => ref .read(channelMessagesProvider(channelId).notifier) .removeLocalMessage(eventId), + onBeforeSend: () async { + if (ref.read(pocketVoiceProvider).enabled) { + await ref.read(pocketVoiceProvider.notifier).interrupt(); + } + }, ); }); diff --git a/mobile/lib/features/channels/thread_detail_page.dart b/mobile/lib/features/channels/thread_detail_page.dart index d00973b4d1..2e1898e022 100644 --- a/mobile/lib/features/channels/thread_detail_page.dart +++ b/mobile/lib/features/channels/thread_detail_page.dart @@ -7,9 +7,11 @@ import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; +import '../../shared/voice/pocket_voice_controller.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile.dart'; import 'channel_link_navigation.dart'; +import 'channel_management_provider.dart'; import 'channel_typing_provider.dart'; import 'thread_replies_provider.dart'; import 'channels_provider.dart'; @@ -20,6 +22,8 @@ import '../profile/user_profile_sheet.dart'; import 'message_actions.dart'; import 'message_content.dart'; import 'mentions/mention_candidates_provider.dart'; +import 'pocket_voice_button.dart'; +import 'pocket_voice_conversation.dart'; import 'reaction_row.dart'; import 'read_state/read_state_format.dart'; import 'read_state/read_state_provider.dart'; @@ -67,6 +71,35 @@ class ThreadDetailPage extends HookConsumerWidget { }); final fetchedReplies = replyMessages.value; + final membersState = ref.watch(channelMembersProvider(channelId)); + final voiceConversation = useMemoized(PocketVoiceConversation.new); + final voiceEventKey = + repliesState.value?.map((event) => event.id).join(',') ?? ''; + useEffect( + () { + final events = repliesState.value; + if (events == null) return null; + final spoken = voiceConversation.update( + events: events, + members: membersState.value, + currentPubkey: currentPubkey, + threadHeadId: threadHead.id, + ); + for (final event in spoken) { + ref + .read(pocketVoiceProvider.notifier) + .speak('thread:$channelId:${threadHead.id}', event.content); + } + return null; + }, + [ + voiceEventKey, + membersState.value, + currentPubkey, + channelId, + threadHead.id, + ], + ); final allMsgs = fetchedReplies == null ? allMessages : [ @@ -153,7 +186,14 @@ class ThreadDetailPage extends HookConsumerWidget { }); return FrostedScaffold( - appBar: const FrostedAppBar(title: Text('Thread')), + appBar: FrostedAppBar( + title: const Text('Thread'), + actions: [ + PocketVoiceButton( + conversationKey: 'thread:$channelId:${threadHead.id}', + ), + ], + ), body: Column( children: [ Expanded( diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index f089f63915..4ec6dbfb12 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -9,6 +9,8 @@ import '../../shared/auth/auth.dart'; import '../../shared/clipboard_utils.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/voice/pocket_model_manifest.dart'; +import '../../shared/voice/pocket_model_provider.dart'; import '../../shared/widgets/app_list.dart'; import '../../shared/widgets/app_list_card.dart'; import '../../shared/widgets/frosted_app_bar.dart'; @@ -18,6 +20,7 @@ import 'theme_picker_page.dart'; part 'settings_page/appearance_section.dart'; part 'settings_page/connection_section.dart'; +part 'settings_page/voice_section.dart'; class SettingsPage extends HookConsumerWidget { const SettingsPage({super.key, required this.profileHeader}); @@ -42,6 +45,7 @@ class SettingsPage extends HookConsumerWidget { children: [ profileHeader, const _AppearanceSection(), + const _VoiceSection(), const _ConnectionSection(), const _RemoveCommunitySection(), ], diff --git a/mobile/lib/features/settings/settings_page/voice_section.dart b/mobile/lib/features/settings/settings_page/voice_section.dart new file mode 100644 index 0000000000..e3a2cb14ef --- /dev/null +++ b/mobile/lib/features/settings/settings_page/voice_section.dart @@ -0,0 +1,68 @@ +part of '../settings_page.dart'; + +class _VoiceSection extends ConsumerWidget { + const _VoiceSection(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final model = ref.watch(pocketModelProvider); + final downloading = model.phase == PocketModelPhase.downloading; + final status = switch (model.phase) { + PocketModelPhase.checking => 'Checking…', + PocketModelPhase.absent => 'Not downloaded', + PocketModelPhase.downloading => + '${(model.progress * 100).clamp(0, 100).round()}%', + PocketModelPhase.verifying => 'Verifying…', + PocketModelPhase.ready => 'Ready', + PocketModelPhase.cancelled => 'Cancelled', + PocketModelPhase.insufficientSpace => 'Needs more space', + PocketModelPhase.error => 'Download failed', + }; + final action = switch (model.phase) { + PocketModelPhase.ready || + PocketModelPhase.checking || + PocketModelPhase.verifying => null, + PocketModelPhase.downloading => + () => ref.read(pocketModelProvider.notifier).cancel(), + _ => () => ref.read(pocketModelProvider.notifier).download(), + }; + + return AppListCard( + label: 'Voice', + children: [ + AppListRow( + icon: LucideIcons.audioLines, + title: 'Pocket voice', + subtitle: + model.message ?? + 'Private, on-device speech. Downloads 473 MB after install.', + subtitleMaxLines: 3, + value: status, + trailing: downloading + ? SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + value: model.total == 0 ? null : model.progress, + strokeWidth: 2, + ), + ) + : action == null + ? null + : Icon( + downloading ? LucideIcons.x : LucideIcons.download, + size: 18, + color: context.colors.primary, + ), + onTap: action, + ), + const AppListRow( + icon: LucideIcons.hardDriveDownload, + title: 'Model storage', + value: '${pocketModelRuntimeBytes ~/ 1000000} MB', + subtitle: 'Stored in application support, not duplicated in assets.', + subtitleMaxLines: 2, + ), + ], + ); + } +} diff --git a/mobile/lib/shared/voice/pocket_model_downloader.dart b/mobile/lib/shared/voice/pocket_model_downloader.dart new file mode 100644 index 0000000000..60cbdb1ad8 --- /dev/null +++ b/mobile/lib/shared/voice/pocket_model_downloader.dart @@ -0,0 +1,357 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; +import 'package:path_provider/path_provider.dart'; +import 'package:pointycastle/digests/sha256.dart'; +import 'package:uuid/uuid.dart'; + +import 'pocket_model_manifest.dart'; + +const _manifestName = '.buzz-model-manifest'; +const _storageChannel = MethodChannel('buzz/voice_audio'); +const _minimumReserveBytes = 64 * 1024 * 1024; + +class PocketDownloadCancelled implements Exception { + const PocketDownloadCancelled(); +} + +class PocketInsufficientSpace implements Exception { + final int required; + final int available; + + const PocketInsufficientSpace(this.required, this.available); +} + +class PocketModelDownloadException implements Exception { + final String message; + final bool retryable; + + const PocketModelDownloadException(this.message, {this.retryable = false}); + + @override + String toString() => message; +} + +typedef PocketDownloadProgress = + void Function(int downloaded, int total, String currentFile); + +class PocketModelDownloader { + final http.Client Function() clientFactory; + final Future Function() applicationSupportDirectory; + final Future Function(String path) availableCapacity; + final Future Function(String path) excludeFromBackup; + final List artifacts; + final String installationId; + + http.Client? _client; + bool _cancelled = false; + + PocketModelDownloader({ + http.Client Function()? clientFactory, + Future Function()? applicationSupportDirectory, + Future Function(String path)? availableCapacity, + Future Function(String path)? excludeFromBackup, + this.artifacts = const [], + String? installationId, + }) : clientFactory = clientFactory ?? http.Client.new, + applicationSupportDirectory = + applicationSupportDirectory ?? getApplicationSupportDirectory, + availableCapacity = availableCapacity ?? _platformAvailableCapacity, + excludeFromBackup = excludeFromBackup ?? _platformExcludeFromBackup, + installationId = installationId ?? const Uuid().v4(); + + List get _artifacts => + artifacts.isEmpty ? pocketModelArtifacts : artifacts; + + int get _downloadBytes => + _artifacts.fold(0, (total, artifact) => total + artifact.size); + + Future modelDirectory() async { + final support = await applicationSupportDirectory(); + return Directory( + '${support.path}/buzz/models/pocket-tts/v$pocketModelVersion', + ); + } + + Future isReady() async { + final directory = await modelDirectory(); + return verify(directory, hashContents: false); + } + + Future verify(Directory directory, {required bool hashContents}) async { + final manifest = File('${directory.path}/$_manifestName'); + if (!await manifest.isFile()) return false; + try { + final decoded = + jsonDecode(await manifest.readAsString()) as Map; + if (decoded['version'] != pocketModelVersion || + decoded['complete'] != true) { + return false; + } + for (final artifact in _artifacts) { + final file = File('${directory.path}/${artifact.name}'); + if (!await file.isFile() || await file.length() != artifact.size) { + return false; + } + if (hashContents && await _sha256File(file) != artifact.sha256) { + return false; + } + } + return File('${directory.path}/MODEL_LICENSE.txt').isFile(); + } catch (_) { + return false; + } + } + + Future install(PocketDownloadProgress onProgress) async { + _cancelled = false; + final finalDirectory = await modelDirectory(); + final parent = finalDirectory.parent; + await parent.create(recursive: true); + await _cleanupAbandoned(parent, finalDirectory.path); + + final available = await availableCapacity(parent.path); + final reserve = max(_minimumReserveBytes, (_downloadBytes * 0.1).ceil()); + final required = _downloadBytes + reserve; + if (available < required) { + throw PocketInsufficientSpace(required, available); + } + + final staging = Directory( + '${parent.path}/.pocket-tts-v$pocketModelVersion.install-$installationId', + ); + await staging.create(recursive: true); + var downloaded = 0; + _client = clientFactory(); + try { + for (final artifact in _artifacts) { + _throwIfCancelled(); + await _ensureCapacity(parent.path, artifact.size); + await _downloadArtifact( + artifact, + staging, + (fileBytes) => + onProgress(downloaded + fileBytes, _downloadBytes, artifact.name), + ); + downloaded += artifact.size; + onProgress(downloaded, _downloadBytes, artifact.name); + } + + await File( + '${staging.path}/MODEL_LICENSE.txt', + ).writeAsString(pocketModelLicenseText, flush: true); + await File('${staging.path}/$_manifestName').writeAsString( + jsonEncode({ + 'version': pocketModelVersion, + 'complete': true, + 'artifacts': [ + for (final artifact in _artifacts) + { + 'name': artifact.name, + 'size': artifact.size, + 'sha256': artifact.sha256, + }, + ], + }), + flush: true, + ); + if (!await verify(staging, hashContents: true)) { + throw const PocketModelDownloadException( + 'Pocket model verification failed after download.', + ); + } + + final backup = Directory( + '${parent.path}/.pocket-tts-v$pocketModelVersion.old-$installationId', + ); + if (await finalDirectory.exists()) { + await finalDirectory.rename(backup.path); + } + try { + await staging.rename(finalDirectory.path); + } catch (_) { + if (await backup.exists() && !await finalDirectory.exists()) { + await backup.rename(finalDirectory.path); + } + rethrow; + } + if (await backup.exists()) { + await backup.delete(recursive: true); + } + await excludeFromBackup(finalDirectory.path); + return finalDirectory; + } on PocketDownloadCancelled { + rethrow; + } finally { + _client?.close(); + _client = null; + if (await staging.exists()) { + await staging.delete(recursive: true); + } + } + } + + void cancel() { + _cancelled = true; + _client?.close(); + } + + Future _downloadArtifact( + PocketModelArtifact artifact, + Directory staging, + void Function(int fileBytes) onProgress, + ) async { + Object? lastError; + for (var attempt = 0; attempt < 3; attempt += 1) { + _throwIfCancelled(); + final part = File('${staging.path}/${artifact.name}.part'); + if (await part.exists()) await part.delete(); + try { + final request = http.Request('GET', artifact.url); + final response = await _client!.send(request); + if (response.statusCode != HttpStatus.ok) { + final retryable = + response.statusCode == 408 || + response.statusCode == 429 || + response.statusCode >= 500; + throw PocketModelDownloadException( + '${artifact.name} download returned HTTP ' + '${response.statusCode}.', + retryable: retryable, + ); + } + if (response.contentLength case final length? + when length != artifact.size) { + throw PocketModelDownloadException( + '${artifact.name} expected ${artifact.size} bytes, ' + 'server reported $length.', + ); + } + + final digest = SHA256Digest(); + final sink = part.openWrite(); + var bytes = 0; + try { + await for (final chunk in response.stream) { + _throwIfCancelled(); + bytes += chunk.length; + if (bytes > artifact.size) { + throw PocketModelDownloadException( + '${artifact.name} exceeded ${artifact.size} bytes.', + ); + } + final typed = Uint8List.fromList(chunk); + digest.update(typed, 0, typed.length); + sink.add(chunk); + onProgress(bytes); + } + await sink.flush(); + } finally { + await sink.close(); + } + if (bytes != artifact.size) { + throw PocketModelDownloadException( + '${artifact.name} expected ${artifact.size} bytes, received $bytes.', + retryable: true, + ); + } + final output = Uint8List(digest.digestSize); + digest.doFinal(output, 0); + final hash = output + .map((byte) => byte.toRadixString(16).padLeft(2, '0')) + .join(); + if (hash != artifact.sha256) { + throw PocketModelDownloadException( + '${artifact.name} checksum did not match.', + ); + } + await part.rename('${staging.path}/${artifact.name}'); + return; + } on PocketDownloadCancelled { + rethrow; + } catch (error) { + _throwIfCancelled(); + lastError = error; + if (await part.exists()) await part.delete(); + final retryable = + error is SocketException || + error is TimeoutException || + (error is PocketModelDownloadException && error.retryable); + if (!retryable || attempt == 2) break; + await Future.delayed(Duration(milliseconds: 250 << attempt)); + } + } + if (lastError is PocketModelDownloadException) throw lastError; + throw PocketModelDownloadException( + 'Unable to download ${artifact.name}: $lastError', + retryable: true, + ); + } + + Future _ensureCapacity(String path, int nextFileBytes) async { + final available = await availableCapacity(path); + final reserve = max(_minimumReserveBytes, (nextFileBytes * 0.1).ceil()); + if (available < nextFileBytes + reserve) { + throw PocketInsufficientSpace(nextFileBytes + reserve, available); + } + } + + Future _cleanupAbandoned(Directory parent, String finalPath) async { + if (!await parent.exists()) return; + final finalDirectory = Directory(finalPath); + final backups = []; + await for (final entity in parent.list()) { + if (entity.path == finalPath) continue; + final name = entity.uri.pathSegments + .where((segment) => segment.isNotEmpty) + .last; + if (entity is! Directory) continue; + if (name.startsWith('.pocket-tts-v$pocketModelVersion.old-')) { + backups.add(entity); + } else if (name.startsWith('.pocket-tts-v$pocketModelVersion.install-')) { + await entity.delete(recursive: true); + } + } + if (!await finalDirectory.exists() && backups.isNotEmpty) { + backups.sort( + (left, right) => + right.statSync().modified.compareTo(left.statSync().modified), + ); + await backups.removeAt(0).rename(finalPath); + } + for (final backup in backups) { + if (await backup.exists()) await backup.delete(recursive: true); + } + } + + void _throwIfCancelled() { + if (_cancelled) throw const PocketDownloadCancelled(); + } +} + +Future _sha256File(File file) async { + final digest = SHA256Digest(); + await for (final chunk in file.openRead()) { + final typed = Uint8List.fromList(chunk); + digest.update(typed, 0, typed.length); + } + final output = Uint8List(digest.digestSize); + digest.doFinal(output, 0); + return output.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); +} + +Future _platformAvailableCapacity(String path) async => + await _storageChannel.invokeMethod('availableCapacity', path) ?? 0; + +Future _platformExcludeFromBackup(String path) => + _storageChannel.invokeMethod('excludeFromBackup', path); + +extension on File { + Future isFile() async => + await exists() && + await stat().then((stat) => stat.type == FileSystemEntityType.file); +} diff --git a/mobile/lib/shared/voice/pocket_model_manifest.dart b/mobile/lib/shared/voice/pocket_model_manifest.dart new file mode 100644 index 0000000000..0445d9fc6b --- /dev/null +++ b/mobile/lib/shared/voice/pocket_model_manifest.dart @@ -0,0 +1,103 @@ +import 'package:flutter/foundation.dart'; + +@immutable +class PocketModelArtifact { + final String name; + final Uri url; + final int size; + final String sha256; + + const PocketModelArtifact({ + required this.name, + required this.url, + required this.size, + required this.sha256, + }); +} + +const pocketModelVersion = '3'; +const pocketModelRuntimeBytes = 473251634; +const pocketModelDownloadBytes = 473270289; +const _pocketModelBase = + 'https://huggingface.co/csukuangfj2/' + 'sherpa-onnx-pocket-tts-2026-01-26/resolve/' + '96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3'; +const _pocketVoiceUrl = + 'https://huggingface.co/kyutai/tts-voices/resolve/' + '323332d33f997de8394f24a193e1a76df720e01a/' + 'vctk/p333_023_enhanced.wav'; + +final pocketModelArtifacts = [ + _artifact( + 'decoder.onnx', + 41478706, + 'f267880fde6c58b17b0a8f3647eaf8dcfad321f833f32d583ebc2fb2d1a15f10', + ), + _artifact( + 'encoder.onnx', + 72713165, + 'e8f2f6d301ffb96e398b138a7dc6d3038622d236044636b73d920bab85890260', + ), + _artifact( + 'lm_flow.onnx', + 39097094, + '79c013a554a54e63319c33c0cc8830cbbedc9b7e448ae7e26f7923ae11f9873e', + ), + _artifact( + 'lm_main.onnx', + 302742148, + '255d1a9263c5abdf36034abfc19c11d21cc5f40f0f87d8361288e972cbd5c578', + ), + _artifact( + 'text_conditioner.onnx', + 16388343, + '0b84e837d7bfaf2c896627b03e3f080320309f37f4fc7df7698c644f7ba5e6b1', + ), + _artifact( + 'vocab.json', + 69478, + '6fb646346cf931016f70c4921aab0900ce7a304b893cb02135c74e294abfea01', + ), + _artifact( + 'token_scores.json', + 123616, + '5be2f278caf9b9800741f0fd82bff677f4943ec764c356f907213434b622d958', + ), + PocketModelArtifact( + name: 'reference_sample.wav', + url: Uri.parse(_pocketVoiceUrl), + size: 639084, + sha256: 'a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f', + ), + _artifact( + 'LICENSE', + 18655, + 'fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6', + ), +]; + +PocketModelArtifact _artifact(String name, int size, String sha256) => + PocketModelArtifact( + name: name, + url: Uri.parse('$_pocketModelBase/$name'), + size: size, + sha256: sha256, + ); + +const pocketModelLicenseText = ''' +Pocket TTS +© Kyutai. + +Licensed under the Creative Commons Attribution 4.0 International License +(CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/ + +Original model: https://huggingface.co/kyutai/pocket-tts +ONNX export: https://huggingface.co/KevinAHM/pocket-tts-onnx +Sherpa-onnx repackage: +https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26 + +Reference voice: Mary (VCTK p333, enhanced by ai-coustics), from +https://huggingface.co/kyutai/tts-voices + +Provided "AS IS", without warranty of any kind, express or implied. +'''; diff --git a/mobile/lib/shared/voice/pocket_model_provider.dart b/mobile/lib/shared/voice/pocket_model_provider.dart new file mode 100644 index 0000000000..37aad14051 --- /dev/null +++ b/mobile/lib/shared/voice/pocket_model_provider.dart @@ -0,0 +1,128 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'pocket_model_downloader.dart'; + +enum PocketModelPhase { + checking, + absent, + downloading, + verifying, + ready, + cancelled, + insufficientSpace, + error, +} + +@immutable +class PocketModelState { + final PocketModelPhase phase; + final int downloaded; + final int total; + final String? currentFile; + final String? path; + final String? message; + final int? requiredBytes; + final int? availableBytes; + + const PocketModelState({ + required this.phase, + this.downloaded = 0, + this.total = 0, + this.currentFile, + this.path, + this.message, + this.requiredBytes, + this.availableBytes, + }); + + double get progress => total == 0 ? 0 : downloaded / total; +} + +final pocketModelDownloaderProvider = Provider( + (_) => PocketModelDownloader(), +); + +final pocketModelProvider = + NotifierProvider( + PocketModelNotifier.new, + ); + +class PocketModelNotifier extends Notifier { + PocketModelDownloader? _active; + + @override + PocketModelState build() { + ref.onDispose(() => _active?.cancel()); + Future.microtask(check); + return const PocketModelState(phase: PocketModelPhase.checking); + } + + Future check() async { + final downloader = ref.read(pocketModelDownloaderProvider); + if (await downloader.isReady()) { + final directory = await downloader.modelDirectory(); + state = PocketModelState( + phase: PocketModelPhase.ready, + path: directory.path, + ); + } else { + state = const PocketModelState(phase: PocketModelPhase.absent); + } + } + + Future download() async { + if (_active != null) return; + final downloader = ref.read(pocketModelDownloaderProvider); + _active = downloader; + try { + final directory = await downloader.install((downloaded, total, file) { + state = PocketModelState( + phase: PocketModelPhase.downloading, + downloaded: downloaded, + total: total, + currentFile: file, + ); + }); + state = const PocketModelState(phase: PocketModelPhase.verifying); + if (!await downloader.verify(directory, hashContents: false)) { + throw const PocketModelDownloadException( + 'Pocket model verification failed.', + ); + } + state = PocketModelState( + phase: PocketModelPhase.ready, + path: directory.path, + ); + } on PocketDownloadCancelled { + state = const PocketModelState(phase: PocketModelPhase.cancelled); + } on PocketInsufficientSpace catch (error) { + state = PocketModelState( + phase: PocketModelPhase.insufficientSpace, + requiredBytes: error.required, + availableBytes: error.available, + message: 'Not enough free space for Pocket voice.', + ); + } on FileSystemException catch (error) { + state = PocketModelState( + phase: PocketModelPhase.error, + message: error.osError?.errorCode == 28 + ? 'Pocket voice ran out of disk space.' + : 'Pocket voice storage failed: ${error.message}', + ); + } catch (error) { + state = PocketModelState( + phase: PocketModelPhase.error, + message: error.toString(), + ); + } finally { + _active = null; + } + } + + void cancel() => _active?.cancel(); + + Future retry() => download(); +} diff --git a/mobile/lib/shared/voice/pocket_voice_controller.dart b/mobile/lib/shared/voice/pocket_voice_controller.dart new file mode 100644 index 0000000000..139cb31c6e --- /dev/null +++ b/mobile/lib/shared/voice/pocket_voice_controller.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'pocket_model_provider.dart'; +import 'pocket_voice_worker.dart'; +import 'voice_audio_output.dart'; +import '../relay/relay.dart'; + +enum PocketVoicePhase { off, loading, listening, synthesizing, speaking, error } + +@immutable +class PocketVoiceState { + final PocketVoicePhase phase; + final String? conversationKey; + final String? error; + final Duration? synthesisTime; + final Duration? timeToPlayback; + final double? realTimeFactor; + + const PocketVoiceState({ + this.phase = PocketVoicePhase.off, + this.conversationKey, + this.error, + this.synthesisTime, + this.timeToPlayback, + this.realTimeFactor, + }); + + bool get enabled => phase != PocketVoicePhase.off; +} + +final voiceAudioOutputProvider = Provider( + (_) => PlatformVoiceAudioOutput(), +); + +final pocketVoiceProvider = + NotifierProvider( + PocketVoiceNotifier.new, + ); + +class PocketVoiceNotifier extends Notifier { + PocketVoiceWorker? _worker; + StreamSubscription? _workerSubscription; + StreamSubscription? _audioSubscription; + int _generation = 0; + Stopwatch? _playbackClock; + + @override + PocketVoiceState build() { + ref.listen(relayConfigProvider, (previous, _) { + if (previous != null) unawaited(disable()); + }); + _audioSubscription = ref.read(voiceAudioOutputProvider).events.listen(( + event, + ) { + if (event != VoiceAudioEvent.completed) { + unawaited(interrupt()); + } else if (state.enabled) { + state = PocketVoiceState( + phase: PocketVoicePhase.listening, + conversationKey: state.conversationKey, + synthesisTime: state.synthesisTime, + timeToPlayback: state.timeToPlayback, + realTimeFactor: state.realTimeFactor, + ); + } + }); + ref.onDispose(() { + _workerSubscription?.cancel(); + _audioSubscription?.cancel(); + unawaited(_worker?.dispose()); + }); + return const PocketVoiceState(); + } + + Future enable(String conversationKey) async { + if (state.conversationKey == conversationKey && state.enabled) return; + await disable(); + final model = ref.read(pocketModelProvider); + if (model.phase != PocketModelPhase.ready || model.path == null) { + throw StateError('Download Pocket voice before starting a conversation.'); + } + state = PocketVoiceState( + phase: PocketVoicePhase.loading, + conversationKey: conversationKey, + ); + final worker = PocketVoiceWorker(); + _worker = worker; + _workerSubscription = worker.responses.listen(_handleWorkerResponse); + try { + await worker.start(model.path!); + state = PocketVoiceState( + phase: PocketVoicePhase.listening, + conversationKey: conversationKey, + ); + } catch (error) { + state = PocketVoiceState( + phase: PocketVoicePhase.error, + conversationKey: conversationKey, + error: error.toString(), + ); + rethrow; + } + } + + Future disable() async { + _generation += 1; + await ref.read(voiceAudioOutputProvider).stop(); + _worker?.cancel(); + await _workerSubscription?.cancel(); + _workerSubscription = null; + await _worker?.dispose(); + _worker = null; + state = const PocketVoiceState(); + } + + void speak(String conversationKey, String text) { + if (!state.enabled || state.conversationKey != conversationKey) return; + final trimmed = text.trim(); + if (trimmed.length <= 1 || trimmed.startsWith('[System]')) return; + final bounded = String.fromCharCodes(trimmed.runes.take(2000)); + _generation += 1; + _playbackClock = Stopwatch()..start(); + state = PocketVoiceState( + phase: PocketVoicePhase.synthesizing, + conversationKey: conversationKey, + ); + _worker?.synthesize(_generation, bounded); + } + + Future interrupt() async { + _generation += 1; + _worker?.cancel(); + await ref.read(voiceAudioOutputProvider).stop(); + if (state.enabled) { + state = PocketVoiceState( + phase: PocketVoicePhase.listening, + conversationKey: state.conversationKey, + ); + } + } + + Future _handleWorkerResponse(PocketWorkerResponse response) async { + switch (response) { + case PocketWorkerReady(): + return; + case PocketWorkerStopped(): + return; + case PocketWorkerFailure(): + if (response.generation != null && response.generation != _generation) { + return; + } + state = PocketVoiceState( + phase: PocketVoicePhase.error, + conversationKey: state.conversationKey, + error: response.message, + ); + case PocketWorkerAudio(): + if (response.generation != _generation) return; + final bytes = response.data.materialize().asUint8List(); + final audioSeconds = bytes.length / 2 / response.sampleRate; + final rtf = audioSeconds == 0 + ? null + : response.synthesisTime.inMicroseconds / + Duration.microsecondsPerSecond / + audioSeconds; + await ref + .read(voiceAudioOutputProvider) + .play(Uint8List.fromList(bytes), response.sampleRate); + _playbackClock?.stop(); + state = PocketVoiceState( + phase: PocketVoicePhase.speaking, + conversationKey: state.conversationKey, + synthesisTime: response.synthesisTime, + timeToPlayback: _playbackClock?.elapsed, + realTimeFactor: rtf, + ); + } + } +} diff --git a/mobile/lib/shared/voice/pocket_voice_ffi.dart b/mobile/lib/shared/voice/pocket_voice_ffi.dart new file mode 100644 index 0000000000..1869647707 --- /dev/null +++ b/mobile/lib/shared/voice/pocket_voice_ffi.dart @@ -0,0 +1,130 @@ +import 'dart:ffi' as ffi; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; + +final class _EngineResult extends ffi.Struct { + external ffi.Pointer engine; + external ffi.Pointer error; +} + +final class _PcmResult extends ffi.Struct { + external ffi.Pointer samples; + + @ffi.IntPtr() + external int len; + + @ffi.Uint32() + external int sampleRate; + + external ffi.Pointer error; +} + +typedef _CreateNative = _EngineResult Function(ffi.Pointer); +typedef _CreateDart = _EngineResult Function(ffi.Pointer); +typedef _SynthNative = + _PcmResult Function(ffi.Pointer, ffi.Pointer); +typedef _SynthDart = + _PcmResult Function(ffi.Pointer, ffi.Pointer); +typedef _CancelNative = ffi.Void Function(ffi.Pointer); +typedef _CancelDart = void Function(ffi.Pointer); +typedef _DestroyNative = ffi.Void Function(ffi.Pointer); +typedef _DestroyDart = void Function(ffi.Pointer); +typedef _FreePcmNative = ffi.Void Function(_PcmResult); +typedef _FreePcmDart = void Function(_PcmResult); +typedef _FreeStringNative = ffi.Void Function(ffi.Pointer); +typedef _FreeStringDart = void Function(ffi.Pointer); + +class PocketVoicePcm { + final Uint8List bytes; + final int sampleRate; + + const PocketVoicePcm(this.bytes, this.sampleRate); +} + +class PocketVoiceFfi { + final ffi.DynamicLibrary _library; + late final _CreateDart _create; + late final _SynthDart _synthesize; + late final _CancelDart _cancel; + late final _DestroyDart _destroy; + late final _FreePcmDart _freePcm; + late final _FreeStringDart _freeString; + + PocketVoiceFfi() : _library = _openLibrary() { + _create = _library.lookupFunction<_CreateNative, _CreateDart>( + 'buzz_voice_engine_create', + ); + _synthesize = _library.lookupFunction<_SynthNative, _SynthDart>( + 'buzz_voice_engine_synthesize', + ); + _cancel = _library.lookupFunction<_CancelNative, _CancelDart>( + 'buzz_voice_engine_cancel', + ); + _destroy = _library.lookupFunction<_DestroyNative, _DestroyDart>( + 'buzz_voice_engine_destroy', + ); + _freePcm = _library.lookupFunction<_FreePcmNative, _FreePcmDart>( + 'buzz_voice_pcm_free', + ); + _freeString = _library.lookupFunction<_FreeStringNative, _FreeStringDart>( + 'buzz_voice_string_free', + ); + } + + int create(String modelPath) { + final path = modelPath.toNativeUtf8(); + final result = _create(path); + calloc.free(path); + if (result.error != ffi.nullptr) { + throw StateError(_takeError(result.error)); + } + if (result.engine == ffi.nullptr) { + throw StateError('Pocket engine creation returned a null handle.'); + } + return result.engine.address; + } + + PocketVoicePcm synthesize(int handle, String text) { + final input = text.toNativeUtf8(); + final result = _synthesize( + ffi.Pointer.fromAddress(handle), + input, + ); + calloc.free(input); + try { + if (result.error != ffi.nullptr) { + throw StateError(result.error.cast().toDartString()); + } + if (result.samples == ffi.nullptr) { + throw StateError('Pocket synthesis returned a null sample buffer.'); + } + final sampleBytes = result.samples.cast().asTypedList( + result.len * 2, + ); + return PocketVoicePcm(Uint8List.fromList(sampleBytes), result.sampleRate); + } finally { + _freePcm(result); + } + } + + void cancel(int handle) => _cancel(ffi.Pointer.fromAddress(handle)); + + void destroy(int handle) => + _destroy(ffi.Pointer.fromAddress(handle)); + + String _takeError(ffi.Pointer error) { + final message = error.cast().toDartString(); + _freeString(error); + return message; + } +} + +ffi.DynamicLibrary _openLibrary() { + if (Platform.isIOS) return ffi.DynamicLibrary.process(); + if (Platform.isAndroid) { + return ffi.DynamicLibrary.open('libbuzz_voice_mobile.so'); + } + throw UnsupportedError('Pocket voice is supported only on iOS and Android.'); +} diff --git a/mobile/lib/shared/voice/pocket_voice_worker.dart b/mobile/lib/shared/voice/pocket_voice_worker.dart new file mode 100644 index 0000000000..bbdfb61aae --- /dev/null +++ b/mobile/lib/shared/voice/pocket_voice_worker.dart @@ -0,0 +1,175 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'pocket_voice_ffi.dart'; + +sealed class PocketWorkerResponse { + const PocketWorkerResponse(); +} + +class PocketWorkerReady extends PocketWorkerResponse { + final int handle; + + const PocketWorkerReady(this.handle); +} + +class PocketWorkerAudio extends PocketWorkerResponse { + final int generation; + final TransferableTypedData data; + final int sampleRate; + final Duration synthesisTime; + + const PocketWorkerAudio({ + required this.generation, + required this.data, + required this.sampleRate, + required this.synthesisTime, + }); +} + +class PocketWorkerFailure extends PocketWorkerResponse { + final int? generation; + final String message; + + const PocketWorkerFailure(this.message, {this.generation}); +} + +class PocketWorkerStopped extends PocketWorkerResponse { + const PocketWorkerStopped(); +} + +sealed class _PocketWorkerCommand { + const _PocketWorkerCommand(); +} + +class _Synthesize extends _PocketWorkerCommand { + final int generation; + final String text; + + const _Synthesize(this.generation, this.text); +} + +class _Dispose extends _PocketWorkerCommand { + const _Dispose(); +} + +class PocketVoiceWorker { + final StreamController _responses = + StreamController.broadcast(); + Isolate? _isolate; + SendPort? _commands; + int? _handle; + PocketVoiceFfi? _mainFfi; + ReceivePort? _receive; + + Stream get responses => _responses.stream; + int? get handle => _handle; + + Future start(String modelPath) async { + if (_isolate != null) return; + final receive = ReceivePort(); + _receive = receive; + final messages = receive.asBroadcastStream(); + _isolate = await Isolate.spawn(_workerMain, ( + receive.sendPort, + modelPath, + ), debugName: 'buzz-pocket-voice'); + final first = await messages.first; + if (first is! (SendPort, PocketWorkerResponse)) { + throw StateError('Pocket worker sent an invalid startup response.'); + } + _commands = first.$1; + if (first.$2 is PocketWorkerFailure) { + _isolate?.kill(); + _isolate = null; + receive.close(); + _receive = null; + throw StateError((first.$2 as PocketWorkerFailure).message); + } + final ready = first.$2 as PocketWorkerReady; + _handle = ready.handle; + _mainFfi = PocketVoiceFfi(); + messages.listen((message) { + if (message is PocketWorkerResponse) _responses.add(message); + }); + } + + void synthesize(int generation, String text) { + _commands?.send(_Synthesize(generation, text)); + } + + void cancel() { + final handle = _handle; + if (handle != null) _mainFfi?.cancel(handle); + } + + Future dispose() async { + if (_responses.isClosed) return; + cancel(); + final stopped = _responses.stream.firstWhere( + (response) => response is PocketWorkerStopped, + ); + final commands = _commands; + _commands = null; + _handle = null; + _mainFfi = null; + commands?.send(const _Dispose()); + if (commands != null) { + try { + await stopped.timeout(const Duration(seconds: 10)); + } on TimeoutException { + _isolate?.kill(); + } + } + _isolate?.kill(); + _isolate = null; + _receive?.close(); + _receive = null; + await _responses.close(); + } +} + +void _workerMain((SendPort, String) startup) { + final output = startup.$1; + final commands = ReceivePort(); + final ffi = PocketVoiceFfi(); + late final int handle; + try { + handle = ffi.create(startup.$2); + } catch (error) { + output.send((commands.sendPort, PocketWorkerFailure(error.toString()))); + commands.close(); + return; + } + output.send((commands.sendPort, PocketWorkerReady(handle))); + commands.listen((command) { + switch (command) { + case _Synthesize(): + final stopwatch = Stopwatch()..start(); + try { + final pcm = ffi.synthesize(handle, command.text); + stopwatch.stop(); + output.send( + PocketWorkerAudio( + generation: command.generation, + data: TransferableTypedData.fromList([pcm.bytes]), + sampleRate: pcm.sampleRate, + synthesisTime: stopwatch.elapsed, + ), + ); + } catch (error) { + output.send( + PocketWorkerFailure( + error.toString(), + generation: command.generation, + ), + ); + } + case _Dispose(): + ffi.destroy(handle); + commands.close(); + output.send(const PocketWorkerStopped()); + Isolate.exit(); + } + }); +} diff --git a/mobile/lib/shared/voice/voice_audio_output.dart b/mobile/lib/shared/voice/voice_audio_output.dart new file mode 100644 index 0000000000..195cb73ec8 --- /dev/null +++ b/mobile/lib/shared/voice/voice_audio_output.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; + +enum VoiceAudioEvent { completed, interrupted, routeLost, backgrounded, error } + +abstract interface class VoiceAudioOutput { + Stream get events; + Future play(Uint8List pcm, int sampleRate); + Future stop(); +} + +class PlatformVoiceAudioOutput implements VoiceAudioOutput { + static const _channel = MethodChannel('buzz/voice_audio'); + final StreamController _events = + StreamController.broadcast(); + + PlatformVoiceAudioOutput() { + _channel.setMethodCallHandler((call) async { + final event = switch (call.method) { + 'completed' => VoiceAudioEvent.completed, + 'interrupted' => VoiceAudioEvent.interrupted, + 'routeLost' => VoiceAudioEvent.routeLost, + 'backgrounded' => VoiceAudioEvent.backgrounded, + _ => VoiceAudioEvent.error, + }; + _events.add(event); + }); + } + + @override + Stream get events => _events.stream; + + @override + Future play(Uint8List pcm, int sampleRate) => + _channel.invokeMethod('play', {'pcm': pcm, 'sampleRate': sampleRate}); + + @override + Future stop() => _channel.invokeMethod('stop'); +} diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 55185fc9c6..f8ea7973f2 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -346,7 +346,7 @@ packages: source: hosted version: "1.3.3" ffi: - dependency: transitive + dependency: "direct main" description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" @@ -446,6 +446,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" flutter_hooks: dependency: "direct main" description: @@ -568,6 +573,11 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" glob: dependency: transitive description: @@ -712,6 +722,11 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" intl: dependency: "direct main" description: @@ -1032,6 +1047,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.2" + process: + dependency: transitive + description: + name: process + sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744 + url: "https://pub.dev" + source: hosted + version: "5.0.5" provider: dependency: transitive description: @@ -1261,6 +1284,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" term_glyph: dependency: transitive description: @@ -1493,6 +1524,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade" + url: "https://pub.dev" + source: hosted + version: "3.1.0" webkit_inspection_protocol: dependency: transitive description: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index b90f50c25a..7d527a8b6a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -9,6 +9,7 @@ environment: dependencies: flutter: sdk: flutter + ffi: ^2.1.4 hooks_riverpod: ^3.0.3 flutter_hooks: ^0.21.3 lucide_icons_flutter: ^3.1.0 @@ -40,6 +41,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter_lints: ^6.0.0 custom_lint: ^0.8.0 riverpod_lint: ^3.1.0 diff --git a/mobile/test/features/channels/pocket_voice_conversation_test.dart b/mobile/test/features/channels/pocket_voice_conversation_test.dart new file mode 100644 index 0000000000..3e414b4cb2 --- /dev/null +++ b/mobile/test/features/channels/pocket_voice_conversation_test.dart @@ -0,0 +1,101 @@ +import 'package:buzz/features/channels/channel_management_provider.dart'; +import 'package:buzz/features/channels/pocket_voice_conversation.dart'; +import 'package:buzz/shared/relay/relay.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final bot = ChannelMember( + pubkey: 'bot', + role: 'bot', + joinedAt: DateTime.utc(2026), + ); + final person = ChannelMember( + pubkey: 'person', + role: 'member', + joinedAt: DateTime.utc(2026), + ); + + test('baselines history, then selects only new authoritative bot text', () { + final conversation = PocketVoiceConversation(); + expect( + conversation.update( + events: [_event('history', 'bot', 'old')], + members: [bot, person], + currentPubkey: 'self', + ), + isEmpty, + ); + + final spoken = conversation.update( + events: [ + _event('history', 'bot', 'old'), + _event('person', 'person', 'hello'), + _event('self', 'self', 'steer'), + _event('system', 'bot', '[System] working'), + _event('bot', 'bot', 'Assistant answer'), + ], + members: [bot, person], + currentPubkey: 'self', + ); + + expect(spoken.map((event) => event.id), ['bot']); + }); + + test('fails closed while membership is unresolved', () { + final conversation = PocketVoiceConversation(); + conversation.update(events: const [], members: null, currentPubkey: 'self'); + expect( + conversation.update( + events: [_event('bot', 'bot', 'answer')], + members: null, + currentPubkey: 'self', + ), + isEmpty, + ); + expect( + conversation.update( + events: [_event('bot', 'bot', 'answer')], + members: [bot], + currentPubkey: 'self', + ), + isEmpty, + ); + }); + + test('thread conversation accepts only direct replies to its head', () { + final conversation = PocketVoiceConversation(); + conversation.update( + events: const [], + members: [bot], + currentPubkey: 'self', + threadHeadId: 'root', + ); + + final spoken = conversation.update( + events: [ + _event('top', 'bot', 'top level'), + _event('direct', 'bot', 'direct', parent: 'root'), + _event('nested', 'bot', 'nested', parent: 'other'), + ], + members: [bot], + currentPubkey: 'self', + threadHeadId: 'root', + ); + + expect(spoken.map((event) => event.id), ['direct']); + }); +} + +NostrEvent _event(String id, String pubkey, String content, {String? parent}) => + NostrEvent( + id: id, + pubkey: pubkey, + createdAt: 1, + kind: EventKind.streamMessage, + tags: [ + const ['h', 'channel'], + if (parent != null) ['e', parent, '', 'reply'], + ], + content: content, + sig: '', + ); diff --git a/mobile/test/features/channels/send_message_provider_test.dart b/mobile/test/features/channels/send_message_provider_test.dart index f91ce87b2b..951f004de7 100644 --- a/mobile/test/features/channels/send_message_provider_test.dart +++ b/mobile/test/features/channels/send_message_provider_test.dart @@ -66,6 +66,30 @@ void main() { expect(completedIds, isEmpty); expect(removedIds, [localMessages.single.id]); }); + + test('interrupts voice before publishing submitted user text', () async { + final session = _PendingPublishRelaySession(); + var interrupted = false; + final send = SendMessage( + signedEventRelay: SignedEventRelay( + session: session, + nsec: nostr.Keys.generate().nsec, + ), + fetchMembers: (_) async => const [], + readUserCache: () => const {}, + addLocalMessage: (_, _) { + expect(interrupted, isTrue); + }, + completeLocalMessage: (_, _) {}, + removeLocalMessage: (_, _) {}, + onBeforeSend: () async => interrupted = true, + ); + + final result = send(channelId: _channelId, content: 'steer'); + await session.published; + session.accept(); + await result; + }); } const _channelId = '11111111-1111-4111-8111-111111111111'; diff --git a/mobile/test/features/settings/goldens/pocket_voice_settings.png b/mobile/test/features/settings/goldens/pocket_voice_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..c8ddeed181233817e0cbbb8ea0b34ee018ca0beb GIT binary patch literal 52489 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

~=!@SJR!a#dHeI!nq~6Cp_l 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 Dlqk7v literal 0 HcmV?d00001 diff --git a/mobile/test/features/settings/pocket_voice_settings_test.dart b/mobile/test/features/settings/pocket_voice_settings_test.dart new file mode 100644 index 0000000000..34721cff90 --- /dev/null +++ b/mobile/test/features/settings/pocket_voice_settings_test.dart @@ -0,0 +1,67 @@ +import 'package:buzz/features/settings/settings_page.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:buzz/shared/voice/pocket_model_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../helpers/widget_helpers.dart'; + +void main() { + testWidgets('shows post-install Pocket model download details', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + PackageInfo.setMockInitialValues( + appName: 'Buzz', + packageName: 'xyz.block.buzz.mobile', + version: '1.0.0', + buildNumber: '1', + buildSignature: '', + ); + final preferences = await SharedPreferences.getInstance(); + await (FontLoader( + 'Inter', + )..addFont(rootBundle.load('assets/fonts/InterVariable.ttf'))).load(); + await (FontLoader('packages/lucide_icons_flutter/Lucide')..addFont( + rootBundle.load('packages/lucide_icons_flutter/assets/lucide.ttf'), + )) + .load(); + const screenshotKey = Key('pocket-voice-settings-screenshot'); + await tester.binding.setSurfaceSize(const Size(430, 932)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + await tester.pumpWidget( + WidgetHelpers.testable( + overrides: [ + savedPrefsProvider.overrideWithValue(preferences), + pocketModelProvider.overrideWith(_AbsentPocketModelNotifier.new), + ], + child: const RepaintBoundary( + key: screenshotKey, + child: SettingsPage(profileHeader: SizedBox.shrink()), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 500)); + + expect(find.text('Pocket voice'), findsOneWidget); + expect(find.text('Not downloaded'), findsOneWidget); + expect(find.textContaining('Downloads 473 MB'), findsOneWidget); + expect(find.text('473 MB'), findsOneWidget); + + await expectLater( + find.byKey(screenshotKey), + matchesGoldenFile('goldens/pocket_voice_settings.png'), + ); + }); +} + +class _AbsentPocketModelNotifier extends PocketModelNotifier { + @override + PocketModelState build() => + const PocketModelState(phase: PocketModelPhase.absent); +} diff --git a/mobile/test/shared/voice/pocket_model_downloader_test.dart b/mobile/test/shared/voice/pocket_model_downloader_test.dart new file mode 100644 index 0000000000..bebd73e654 --- /dev/null +++ b/mobile/test/shared/voice/pocket_model_downloader_test.dart @@ -0,0 +1,169 @@ +import 'dart:io'; +import 'dart:async'; + +import 'package:buzz/shared/voice/pocket_model_downloader.dart'; +import 'package:buzz/shared/voice/pocket_model_manifest.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + late Directory temporary; + late PocketModelArtifact artifact; + + setUp(() async { + temporary = await Directory.systemTemp.createTemp('buzz-pocket-model-'); + artifact = PocketModelArtifact( + name: 'tiny.bin', + url: Uri.parse('https://example.test/tiny.bin'), + size: 5, + sha256: + '2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e730' + '43362938b9824', + ); + }); + + tearDown(() async { + if (await temporary.exists()) await temporary.delete(recursive: true); + }); + + test( + 'installs verified files atomically and reports exact progress', + () async { + final excluded = []; + final progress = <(int, int, String)>[]; + final downloader = _downloader( + temporary, + artifact, + client: MockClient((_) async => http.Response('hello', 200)), + excludeFromBackup: excluded.add, + ); + + final directory = await downloader.install( + (downloaded, total, file) => progress.add((downloaded, total, file)), + ); + + expect(await downloader.verify(directory, hashContents: true), isTrue); + expect(await File('${directory.path}/tiny.bin').readAsString(), 'hello'); + expect(progress.last, (5, 5, 'tiny.bin')); + expect(excluded, [directory.path]); + expect( + await directory.parent + .list() + .where((entry) => entry.path.contains('.install-')) + .isEmpty, + isTrue, + ); + }, + ); + + test('rejects a checksum mismatch without replacing an install', () async { + final downloader = _downloader( + temporary, + artifact, + client: MockClient((_) async => http.Response('wrong', 200)), + ); + + await expectLater( + downloader.install((_, _, _) {}), + throwsA(isA()), + ); + + expect( + await downloader.modelDirectory().then((dir) => dir.exists()), + isFalse, + ); + expect( + await Directory( + '${temporary.path}/buzz/models/pocket-tts', + ).list().isEmpty, + isTrue, + ); + }); + + test('fails before download when the model and reserve do not fit', () async { + final downloader = _downloader( + temporary, + artifact, + client: MockClient((_) async => http.Response('hello', 200)), + capacity: 4, + ); + + await expectLater( + downloader.install((_, _, _) {}), + throwsA( + isA() + .having((error) => error.available, 'available', 4) + .having((error) => error.required, 'required', greaterThan(5)), + ), + ); + }); + + test('cancellation closes the transfer and removes staging files', () async { + final client = _PendingClient(); + final downloader = _downloader(temporary, artifact, client: client); + + final install = downloader.install((_, _, _) {}); + await client.started.future; + downloader.cancel(); + + await expectLater(install, throwsA(isA())); + expect(client.closed, isTrue); + expect( + await downloader.modelDirectory().then((directory) => directory.exists()), + isFalse, + ); + }); + + test('recovers an old install left by an interrupted atomic swap', () async { + final parent = Directory('${temporary.path}/buzz/models/pocket-tts'); + final backup = Directory('${parent.path}/.pocket-tts-v3.old-crash'); + await backup.create(recursive: true); + await File('${backup.path}/sentinel').writeAsString('previous install'); + final client = _PendingClient(); + final downloader = _downloader(temporary, artifact, client: client); + + final install = downloader.install((_, _, _) {}); + await client.started.future; + final finalDirectory = await downloader.modelDirectory(); + expect( + await File('${finalDirectory.path}/sentinel').readAsString(), + 'previous install', + ); + downloader.cancel(); + await expectLater(install, throwsA(isA())); + }); +} + +PocketModelDownloader _downloader( + Directory support, + PocketModelArtifact artifact, { + required http.Client client, + int capacity = 1024 * 1024 * 1024, + void Function(String)? excludeFromBackup, +}) => PocketModelDownloader( + clientFactory: () => client, + applicationSupportDirectory: () async => support, + availableCapacity: (_) async => capacity, + excludeFromBackup: (path) async => excludeFromBackup?.call(path), + artifacts: [artifact], + installationId: 'test', +); + +class _PendingClient extends http.BaseClient { + final started = Completer(); + final _response = StreamController>(); + bool closed = false; + + @override + Future send(http.BaseRequest request) async { + started.complete(); + return http.StreamedResponse(_response.stream, 200, contentLength: 5); + } + + @override + void close() { + closed = true; + unawaited(_response.close()); + } +} diff --git a/scripts/mobile-voice-native.sh b/scripts/mobile-voice-native.sh new file mode 100755 index 0000000000..0298bbad24 --- /dev/null +++ b/scripts/mobile-voice-native.sh @@ -0,0 +1,218 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +version="1.13.4" +release_url="https://github.com/k2-fsa/sherpa-onnx/releases/download/v${version}" +cache_root="${BUZZ_VOICE_NATIVE_CACHE:-${HOME}/Library/Caches/buzz-mobile-voice/sherpa-onnx-v${version}}" +generated_root="${repo_root}/mobile/.generated/voice" + +android_archive="sherpa-onnx-v${version}-android.tar.bz2" +android_sha256="7983fc3de23f6e64148f2fb05fa94a2efaa8c0516cc1573383dc5c7d4d2a43b0" +ios_archive="sherpa-onnx-v${version}-ios.tar.bz2" +ios_sha256="596f33bff80046a52144745745fe54d55e8b23659d92209f5ab7d94c1259fe6d" + +usage() { + echo "usage: $0 fetch|build-ios|build-ios-current|build-android|build-all" >&2 + exit 2 +} + +verify_sha256() { + local path="$1" + local expected="$2" + local actual + actual="$(shasum -a 256 "$path" | awk '{print $1}')" + if [[ "$actual" != "$expected" ]]; then + echo "checksum mismatch for $path: expected $expected, got $actual" >&2 + return 1 + fi +} + +fetch_archive() { + local archive="$1" + local expected="$2" + mkdir -p "$cache_root" + if [[ -f "${cache_root}/${archive}" ]] && + ! verify_sha256 "${cache_root}/${archive}" "$expected"; then + rm "${cache_root:?}/${archive}" + fi + if [[ ! -f "${cache_root}/${archive}" ]]; then + curl --fail --location --retry 3 \ + --output "${cache_root}/${archive}.part" \ + "${release_url}/${archive}" + verify_sha256 "${cache_root}/${archive}.part" "$expected" + mv "${cache_root}/${archive}.part" "${cache_root}/${archive}" + fi +} + +extract_archive() { + local archive="$1" + local expected="$2" + local destination="$3" + fetch_archive "$archive" "$expected" + if [[ -f "${destination}/.complete" ]]; then + return + fi + local staging="${destination}.install-$$" + rm -rf "$staging" + mkdir -p "$staging" + tar xjf "${cache_root}/${archive}" -C "$staging" + touch "${staging}/.complete" + rm -rf "$destination" + mv "$staging" "$destination" +} + +fetch_native() { + extract_archive "$android_archive" "$android_sha256" "${cache_root}/android" + extract_archive "$ios_archive" "$ios_sha256" "${cache_root}/ios" +} + +ios_lib_dir() { + local sdk="$1" + local arch="$2" + local sherpa_slice + local ort_slice + if [[ "$sdk" == "iphoneos" ]]; then + sherpa_slice="ios-arm64" + ort_slice="ios-arm64" + else + sherpa_slice="ios-arm64_x86_64-simulator" + ort_slice="ios-arm64_x86_64-simulator" + fi + local destination="${generated_root}/ios/native/${sdk}-${arch}" + mkdir -p "$destination" + local sherpa_binary="${cache_root}/ios/build-ios/sherpa-onnx.xcframework/${sherpa_slice}/libsherpa-onnx.a" + if [[ "$sdk" == "iphoneos" ]]; then + cp "$sherpa_binary" "${destination}/libsherpa-onnx.a" + else + lipo "$sherpa_binary" -thin "$arch" -output "${destination}/libsherpa-onnx.a" + fi + local ort_binary="${cache_root}/ios/build-ios/ios-onnxruntime/1.27.0/onnxruntime.xcframework/${ort_slice}/onnxruntime.framework/onnxruntime" + lipo "$ort_binary" -thin "$arch" -output "${destination}/libonnxruntime.a" + printf '%s\n' "$destination" +} + +build_ios_target() { + local target="$1" + local sdk="$2" + local arch="$3" + local lib_dir + lib_dir="$(ios_lib_dir "$sdk" "$arch")" + IPHONEOS_DEPLOYMENT_TARGET=16.0 SHERPA_ONNX_LIB_DIR="$lib_dir" \ + cargo build --release -p buzz-voice-mobile --target "$target" + local destination="${generated_root}/ios/${sdk}-${arch}" + mkdir -p "$destination" + rm -f "$destination/libbuzz_voice_mobile.a" + cp "${repo_root}/target/${target}/release/libbuzz_voice_mobile.a" "$destination/" +} + +build_ios() { + fetch_native + build_ios_target "aarch64-apple-ios" "iphoneos" "arm64" + build_ios_target "aarch64-apple-ios-sim" "iphonesimulator" "arm64" +} + +build_ios_current() { + fetch_native + local platform="${PLATFORM_NAME:-${SDK_NAME%%[0-9]*}}" + local arch="${CURRENT_ARCH:-}" + if [[ -z "$arch" || "$arch" == "undefined_arch" ]]; then + if [[ " ${ARCHS:-} " == *" arm64 "* ]]; then + arch="arm64" + fi + fi + case "${platform}:${arch}" in + iphoneos:arm64) + build_ios_target "aarch64-apple-ios" "iphoneos" "arm64" + ;; + iphonesimulator:arm64) + build_ios_target "aarch64-apple-ios-sim" "iphonesimulator" "arm64" + ;; + *) + echo "unsupported iOS platform/architecture: ${platform:-unset}/${arch:-unset}" >&2 + exit 1 + ;; + esac +} + +android_target() { + case "$1" in + arm64-v8a) echo "aarch64-linux-android" ;; + x86_64) echo "x86_64-linux-android" ;; + *) return 1 ;; + esac +} + +build_android_abi() { + local abi="$1" + local target + target="$(android_target "$abi")" + local ndk_root="${ANDROID_NDK_HOME:-${ANDROID_NDK_ROOT:-}}" + if [[ -z "$ndk_root" ]]; then + echo "ANDROID_NDK_HOME must point at an Android NDK" >&2 + exit 1 + fi + local toolchain="${ndk_root}/toolchains/llvm/prebuilt/darwin-arm64" + if [[ ! -d "$toolchain" ]]; then + toolchain="${ndk_root}/toolchains/llvm/prebuilt/darwin-x86_64" + fi + local api="${BUZZ_ANDROID_MIN_API:-24}" + local clang_target + if [[ "$abi" == "arm64-v8a" ]]; then + clang_target="aarch64-linux-android${api}" + else + clang_target="x86_64-linux-android${api}" + fi + local linker="${toolchain}/bin/${clang_target}-clang" + if [[ ! -x "$linker" ]]; then + echo "Android linker not found: $linker" >&2 + exit 1 + fi + local source_libs="${cache_root}/android/jniLibs/${abi}" + local target_env + target_env="$(printf '%s' "$target" | tr '[:lower:]-' '[:upper:]_')" + local linker_var="CARGO_TARGET_${target_env}_LINKER" + local rustflags_var="CARGO_TARGET_${target_env}_RUSTFLAGS" + env \ + SHERPA_ONNX_LIB_DIR="$source_libs" \ + "$linker_var=$linker" \ + "$rustflags_var=-L native=${source_libs}" \ + cargo rustc --release -p buzz-voice-mobile --no-default-features \ + --features shared --target "$target" --lib -- --crate-type cdylib + + local destination="${generated_root}/android/jniLibs/${abi}" + mkdir -p "$destination" + local shared_library="" + local candidate + for candidate in "${repo_root}/target/${target}/release/deps/"libbuzz_voice_mobile-*.so; do + if [[ -f "$candidate" ]] && + { [[ -z "$shared_library" ]] || [[ "$candidate" -nt "$shared_library" ]]; }; then + shared_library="$candidate" + fi + done + if [[ -z "$shared_library" ]]; then + echo "Android cdylib was not produced for $target" >&2 + exit 1 + fi + cp "$shared_library" "${destination}/libbuzz_voice_mobile.so" + cp "${source_libs}/libsherpa-onnx-c-api.so" "$destination/" + cp "${source_libs}/libonnxruntime.so" "$destination/" +} + +build_android() { + fetch_native + build_android_abi "arm64-v8a" + build_android_abi "x86_64" +} + +case "${1:-}" in + fetch) fetch_native ;; + build-ios) build_ios ;; + build-ios-current) build_ios_current ;; + build-android) build_android ;; + build-all) + build_ios + build_android + ;; + *) usage ;; +esac From 302d4cad62caf6abc24c0fff393f0d3db5265572 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 05:27:09 -0400 Subject: [PATCH 2/8] fix(mobile): harden Pocket voice playback Signed-off-by: John Tennant --- .../xyz/block/buzz/mobile/VoiceAudioOutput.kt | 26 ++++++--- .../channels/pocket_voice_conversation.dart | 3 +- .../shared/voice/pocket_voice_controller.dart | 24 +++++++- .../pocket_voice_conversation_test.dart | 55 ++++++++++++++----- 4 files changed, 84 insertions(+), 24 deletions(-) diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt index 4b540babef..9c6e7a1306 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt @@ -162,14 +162,26 @@ internal class VoiceAudioOutput( if (written <= 0) break offset += written } - if (generation.get() == currentGeneration) { - nextTrack.stop() - nextTrack.release() - track = null - abandonFocus() - mainHandler.post { + val complete = offset == pcm.size + if (complete) { + val targetFrames = pcm.size.toLong() / 2 + while (generation.get() == currentGeneration) { + val playedFrames = + runCatching { + nextTrack.playbackHeadPosition.toLong() and 0xFFFF_FFFFL + }.getOrNull() ?: break + if (playedFrames >= targetFrames) break + Thread.sleep(5) + } + } + mainHandler.post { + if (generation.get() == currentGeneration && track === nextTrack) { + nextTrack.stop() + nextTrack.release() + track = null + abandonFocus() channel.invokeMethod( - if (offset == pcm.size) "completed" else "error", + if (complete) "completed" else "error", null, ) } diff --git a/mobile/lib/features/channels/pocket_voice_conversation.dart b/mobile/lib/features/channels/pocket_voice_conversation.dart index a4867bbf32..f8c07d3077 100644 --- a/mobile/lib/features/channels/pocket_voice_conversation.dart +++ b/mobile/lib/features/channels/pocket_voice_conversation.dart @@ -42,7 +42,8 @@ class PocketVoiceConversation { final self = currentPubkey?.toLowerCase(); return [ for (final event in unseen) - if (event.kind == EventKind.streamMessage && + if ((event.kind == EventKind.streamMessage || + event.kind == EventKind.streamMessageV2) && event.pubkey.toLowerCase() != self && bots.contains(event.pubkey.toLowerCase()) && event.content.trim().length > 1 && diff --git a/mobile/lib/shared/voice/pocket_voice_controller.dart b/mobile/lib/shared/voice/pocket_voice_controller.dart index 139cb31c6e..5f2ba9eb54 100644 --- a/mobile/lib/shared/voice/pocket_voice_controller.dart +++ b/mobile/lib/shared/voice/pocket_voice_controller.dart @@ -159,6 +159,8 @@ class PocketVoiceNotifier extends Notifier { ); case PocketWorkerAudio(): if (response.generation != _generation) return; + final generation = response.generation; + final conversationKey = state.conversationKey; final bytes = response.data.materialize().asUint8List(); final audioSeconds = bytes.length / 2 / response.sampleRate; final rtf = audioSeconds == 0 @@ -166,9 +168,25 @@ class PocketVoiceNotifier extends Notifier { : response.synthesisTime.inMicroseconds / Duration.microsecondsPerSecond / audioSeconds; - await ref - .read(voiceAudioOutputProvider) - .play(Uint8List.fromList(bytes), response.sampleRate); + final output = ref.read(voiceAudioOutputProvider); + try { + await output.play(Uint8List.fromList(bytes), response.sampleRate); + } catch (error) { + if (generation == _generation && state.enabled) { + state = PocketVoiceState( + phase: PocketVoicePhase.error, + conversationKey: state.conversationKey, + error: error.toString(), + ); + } + return; + } + if (generation != _generation || + !state.enabled || + state.conversationKey != conversationKey) { + await output.stop(); + return; + } _playbackClock?.stop(); state = PocketVoiceState( phase: PocketVoicePhase.speaking, diff --git a/mobile/test/features/channels/pocket_voice_conversation_test.dart b/mobile/test/features/channels/pocket_voice_conversation_test.dart index 3e414b4cb2..f68f92b3a1 100644 --- a/mobile/test/features/channels/pocket_voice_conversation_test.dart +++ b/mobile/test/features/channels/pocket_voice_conversation_test.dart @@ -62,6 +62,30 @@ void main() { ); }); + test('speaks new stream-message v2 bot replies', () { + final conversation = PocketVoiceConversation(); + conversation.update( + events: const [], + members: [bot], + currentPubkey: 'self', + ); + + final spoken = conversation.update( + events: [ + _event( + 'v2', + 'bot', + 'Assistant answer', + kind: EventKind.streamMessageV2, + ), + ], + members: [bot], + currentPubkey: 'self', + ); + + expect(spoken.map((event) => event.id), ['v2']); + }); + test('thread conversation accepts only direct replies to its head', () { final conversation = PocketVoiceConversation(); conversation.update( @@ -86,16 +110,21 @@ void main() { }); } -NostrEvent _event(String id, String pubkey, String content, {String? parent}) => - NostrEvent( - id: id, - pubkey: pubkey, - createdAt: 1, - kind: EventKind.streamMessage, - tags: [ - const ['h', 'channel'], - if (parent != null) ['e', parent, '', 'reply'], - ], - content: content, - sig: '', - ); +NostrEvent _event( + String id, + String pubkey, + String content, { + String? parent, + int kind = EventKind.streamMessage, +}) => NostrEvent( + id: id, + pubkey: pubkey, + createdAt: 1, + kind: kind, + tags: [ + const ['h', 'channel'], + if (parent != null) ['e', parent, '', 'reply'], + ], + content: content, + sig: '', +); From a0524889f2bfc8bb6d2bcce5e010285beba47795 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 05:56:58 -0400 Subject: [PATCH 3/8] fix(mobile): harden Pocket voice lifecycle Signed-off-by: John Tennant --- Cargo.lock | 2 + crates/buzz-voice-mobile/Cargo.toml | 3 +- .../include/buzz_voice_mobile.h | 2 + crates/buzz-voice-mobile/src/lib.rs | 54 +- crates/buzz-voice/Cargo.toml | 1 + crates/buzz-voice/src/lib.rs | 1 + crates/buzz-voice/src/preparation.rs | 761 ++++++++++++++++++ desktop/src-tauri/Cargo.lock | 1 + desktop/src-tauri/src/huddle/preprocessing.rs | 671 +-------------- desktop/src-tauri/src/huddle/tts.rs | 64 +- mobile/android/app/build.gradle.kts | 5 + .../xyz/block/buzz/mobile/VoiceAudioOutput.kt | 88 +- .../pocket_voice_integration_test.dart | 14 + mobile/ios/Flutter/Voice.xcconfig | 2 +- mobile/ios/Runner/VoiceAudioOutput.swift | 36 +- .../channels/pocket_voice_conversation.dart | 9 - .../shared/voice/pocket_model_downloader.dart | 15 +- .../shared/voice/pocket_model_manifest.dart | 26 +- .../shared/voice/pocket_model_provider.dart | 15 +- .../shared/voice/pocket_voice_controller.dart | 335 +++++--- mobile/lib/shared/voice/pocket_voice_ffi.dart | 32 + .../lib/shared/voice/pocket_voice_worker.dart | 97 ++- .../pocket_voice_conversation_test.dart | 17 + .../voice/pocket_model_downloader_test.dart | 106 ++- .../voice/pocket_voice_controller_test.dart | 281 +++++++ scripts/mobile-voice-native.sh | 41 +- 26 files changed, 1753 insertions(+), 926 deletions(-) create mode 100644 crates/buzz-voice/src/preparation.rs create mode 100644 mobile/test/shared/voice/pocket_voice_controller_test.dart diff --git a/Cargo.lock b/Cargo.lock index 5f2f328635..5faa667905 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1295,6 +1295,7 @@ dependencies = [ "ort", "ort-sys", "rand 0.10.1", + "regex", "sentencepiece-model", "serde", "serde_json", @@ -1307,6 +1308,7 @@ name = "buzz-voice-mobile" version = "0.1.0" dependencies = [ "buzz-voice", + "serde_json", ] [[package]] diff --git a/crates/buzz-voice-mobile/Cargo.toml b/crates/buzz-voice-mobile/Cargo.toml index cfb5857095..bd3ba760f1 100644 --- a/crates/buzz-voice-mobile/Cargo.toml +++ b/crates/buzz-voice-mobile/Cargo.toml @@ -8,7 +8,7 @@ repository.workspace = true description = "Minimal mobile C ABI for Buzz Pocket TTS" [lib] -crate-type = ["rlib", "staticlib"] +crate-type = ["rlib", "staticlib", "cdylib"] [features] default = ["static"] @@ -17,3 +17,4 @@ shared = ["buzz-voice/shared"] [dependencies] buzz-voice = { path = "../buzz-voice", default-features = false } +serde_json = { workspace = true } diff --git a/crates/buzz-voice-mobile/include/buzz_voice_mobile.h b/crates/buzz-voice-mobile/include/buzz_voice_mobile.h index fb7a09a86c..cfb199999e 100644 --- a/crates/buzz-voice-mobile/include/buzz_voice_mobile.h +++ b/crates/buzz-voice-mobile/include/buzz_voice_mobile.h @@ -17,8 +17,10 @@ typedef struct BuzzVoicePcm { } BuzzVoicePcm; BuzzVoiceEngineResult buzz_voice_engine_create(const char *model_dir); +char *buzz_voice_prepare_chunks_json(const char *text); BuzzVoicePcm buzz_voice_engine_synthesize(void *engine, const char *text); void buzz_voice_engine_cancel(void *engine); +void buzz_voice_engine_reset_cancel(void *engine); void buzz_voice_engine_destroy(void *engine); void buzz_voice_pcm_free(BuzzVoicePcm pcm); void buzz_voice_string_free(char *value); diff --git a/crates/buzz-voice-mobile/src/lib.rs b/crates/buzz-voice-mobile/src/lib.rs index b94358d4b4..654ca95956 100644 --- a/crates/buzz-voice-mobile/src/lib.rs +++ b/crates/buzz-voice-mobile/src/lib.rs @@ -11,6 +11,7 @@ use buzz_voice::pocket::{ load_text_to_speech, load_voice_style, PocketTts, VoiceStyle, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, }; +use buzz_voice::preparation::{prepare_tts_chunks, shape_tts_chunk}; struct Engine { tts: PocketTts, @@ -95,6 +96,11 @@ pub extern "C" fn buzz_voice_engine_create(model_dir: *const c_char) -> BuzzVoic Ok(value) => value, Err(error) => return engine_error(error), }; + // Pay the model's first-generation setup cost during engine creation so + // the first submitted assistant sentence is not the warm-up utterance. + if let Err(error) = tts.synth_chunk("warmup", "en", &voice, 1) { + eprintln!("buzz-voice-mobile: Pocket warm-up failed: {error}"); + } let engine = Box::new(Engine { tts, voice, @@ -123,7 +129,6 @@ pub extern "C" fn buzz_voice_engine_synthesize( // the caller until `buzz_voice_engine_destroy`; synthesis and destroy must // not overlap. Cancellation only touches the atomic flag. let engine = unsafe { &*(engine.cast::()) }; - engine.cancelled.store(false, Ordering::Release); let cancelled = Arc::clone(&engine.cancelled); let samples = match engine .tts @@ -141,7 +146,7 @@ pub extern "C" fn buzz_voice_engine_synthesize( return pcm_error("synthesis cancelled"); } - let samples: Box<[i16]> = samples + let samples: Box<[i16]> = shape_tts_chunk(samples) .into_iter() .map(|sample| { let sample = if sample.is_finite() { sample } else { 0.0 }; @@ -158,7 +163,26 @@ pub extern "C" fn buzz_voice_engine_synthesize( } } -/// Request cancellation of the current synthesis call. +/// Prepare submitted assistant text and return an owned JSON string array. +/// +/// The returned pointer is released with [`buzz_voice_string_free`]. A null +/// pointer indicates invalid input or an unexpected serialization failure. +#[no_mangle] +pub extern "C" fn buzz_voice_prepare_chunks_json(text: *const c_char) -> *mut c_char { + let text = match input_string(text, "text") { + Ok(value) => value, + Err(_) => return ptr::null_mut(), + }; + let json = match serde_json::to_string(&prepare_tts_chunks(&text)) { + Ok(value) => value, + Err(_) => return ptr::null_mut(), + }; + CString::new(json) + .map(CString::into_raw) + .unwrap_or(ptr::null_mut()) +} + +/// Request cancellation of the current and subsequent synthesis calls. #[no_mangle] pub extern "C" fn buzz_voice_engine_cancel(engine: *mut c_void) { if engine.is_null() { @@ -170,6 +194,17 @@ pub extern "C" fn buzz_voice_engine_cancel(engine: *mut c_void) { engine.cancelled.store(true, Ordering::Release); } +/// Clear sticky cancellation before beginning a new playback request. +#[no_mangle] +pub extern "C" fn buzz_voice_engine_reset_cancel(engine: *mut c_void) { + if engine.is_null() { + return; + } + // SAFETY: See `buzz_voice_engine_synthesize`. + let engine = unsafe { &*(engine.cast::()) }; + engine.cancelled.store(false, Ordering::Release); +} + /// Destroy an engine after any synthesis call has returned. #[no_mangle] pub extern "C" fn buzz_voice_engine_destroy(engine: *mut c_void) { @@ -223,4 +258,17 @@ mod tests { assert!(!pcm.error.is_null()); buzz_voice_pcm_free(pcm); } + + #[test] + fn chunk_preparation_returns_owned_json() { + let input = CString::new("First. Second. Third.").expect("test input"); + let json = buzz_voice_prepare_chunks_json(input.as_ptr()); + assert!(!json.is_null()); + // SAFETY: The function returned a live NUL-terminated owned string. + let value = unsafe { CStr::from_ptr(json) } + .to_str() + .expect("UTF-8 JSON"); + assert_eq!(value, r#"["First.","Second. Third."]"#); + buzz_voice_string_free(json); + } } diff --git a/crates/buzz-voice/Cargo.toml b/crates/buzz-voice/Cargo.toml index 6576fba57b..e492a9e7f1 100644 --- a/crates/buzz-voice/Cargo.toml +++ b/crates/buzz-voice/Cargo.toml @@ -16,6 +16,7 @@ shared = ["sherpa-onnx/shared"] 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 = { workspace = true } +regex = "1" sentencepiece-model = "0.1" serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/buzz-voice/src/lib.rs b/crates/buzz-voice/src/lib.rs index e2ce3f255b..6d46e03de1 100644 --- a/crates/buzz-voice/src/lib.rs +++ b/crates/buzz-voice/src/lib.rs @@ -1,3 +1,4 @@ //! Reusable local voice primitives for Buzz. pub mod pocket; +pub mod preparation; diff --git a/crates/buzz-voice/src/preparation.rs b/crates/buzz-voice/src/preparation.rs new file mode 100644 index 0000000000..69fb2c577d --- /dev/null +++ b/crates/buzz-voice/src/preparation.rs @@ -0,0 +1,761 @@ +//! Shared text preparation and PCM shaping for TTS output. +//! +//! Mental model: +//! +//! ```text +//! raw agent text +//! → strip fenced code blocks → "code block omitted" +//! → strip inline code → bare text +//! → strip URLs → "link omitted" +//! → strip markdown markers → plain text +//! → strip emoji → (removed) +//! → numbers → words → "forty two" +//! → collapse whitespace → clean string +//! ``` +//! +//! Also provides `split_sentences` — the single sentence-boundary splitter used +//! by both the TTS batching pipeline and the Supertonic text chunker. + +use regex::Regex; +use std::sync::LazyLock; + +// ── Sentence splitting ──────────────────────────────────────────────────────── + +/// Regex: a sentence-ending punctuation mark followed by whitespace. +static RE_SENTENCE_BOUNDARY: LazyLock = LazyLock::new(|| Regex::new(r"([.!?])\s+").unwrap()); + +/// Common abbreviations that end with a period but are NOT sentence boundaries. +const ABBREVIATIONS: &[&str] = &[ + "Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.", "Jr.", "St.", "Ave.", "Rd.", "Blvd.", "Dept.", + "Inc.", "Ltd.", "Co.", "Corp.", "etc.", "vs.", "i.e.", "e.g.", "Ph.D.", +]; + +/// Split text into sentence-sized chunks. +/// +/// Combines regex-based boundary detection with: +/// - Abbreviation awareness (`Dr.`, `Mr.`, etc. don't split) +/// - Digit-before-period check (avoids splitting `1.` `2.` numbered lists) +/// - `\n` and `—` treated as sentence breaks +/// +/// Returns non-empty, trimmed strings. +pub fn split_sentences(text: &str) -> Vec { + // First, split on newlines and em-dashes to get coarse segments. + let coarse: Vec<&str> = text.split(['\n', '—']).collect(); + + let mut sentences = Vec::new(); + + for segment in coarse { + let segment = segment.trim(); + if segment.is_empty() { + continue; + } + // Within each segment, split on sentence-ending punctuation. + let matches: Vec<_> = RE_SENTENCE_BOUNDARY.find_iter(segment).collect(); + if matches.is_empty() { + sentences.push(segment.to_string()); + continue; + } + + let mut last_end = 0usize; + for m in &matches { + let before = &segment[last_end..m.start()]; + let punc_char = &segment[m.start()..m.start() + 1]; + + // Skip if this looks like an abbreviation. + let combined = format!("{}{}", before.trim(), punc_char); + let is_abbrev = ABBREVIATIONS.iter().any(|a| combined.ends_with(a)); + + // Skip if the character before the period is a digit (numbered list). + let is_digit_period = punc_char == "." + && !before.is_empty() + && before.ends_with(|c: char| c.is_ascii_digit()); + + if !is_abbrev && !is_digit_period { + let piece = segment[last_end..m.end()].trim(); + if !piece.is_empty() { + sentences.push(piece.to_string()); + } + last_end = m.end(); + } + } + + if last_end < segment.len() { + let tail = segment[last_end..].trim(); + if !tail.is_empty() { + sentences.push(tail.to_string()); + } + } + } + + if sentences.is_empty() { + vec![text.to_string()] + } else { + sentences + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Prepare `text` for TTS synthesis. +/// +/// Applies in order: +/// 1. Fenced code blocks → "code block omitted" +/// 2. Inline code → bare content (backticks stripped) +/// 3. URLs → "link omitted" +/// 4. Markdown bold/italic/underline markers stripped +/// 5. Emoji stripped +/// 6. Numbers → words (integers 0–999, times HH:MM) +/// 7. Excess whitespace collapsed +pub fn preprocess_for_tts(text: &str) -> String { + let s = strip_fenced_code_blocks(text); + let s = strip_inline_code(&s); + let s = strip_urls(&s); + let s = strip_markdown_markers(&s); + let s = strip_emoji(&s); + let s = expand_numbers(&s); + let s = collapse_whitespace(&s); + // Filter trivially short results — ".", ",", etc. would be spoken as + // "period", "comma" by TTS. Agents that have nothing relevant to say + // should not respond at all, but defense-in-depth catches edge cases. + if s.len() <= 1 { + return String::new(); + } + s +} + +// ── Step implementations ────────────────────────────────────────────────────── + +/// Replace fenced code blocks with "code block omitted". +/// +/// Handles both ` ``` ` and `~~~` fences. Multi-line aware. +fn strip_fenced_code_blocks(text: &str) -> String { + let s = replace_fenced(text, "```"); + replace_fenced(&s, "~~~") +} + +fn replace_fenced(text: &str, fence: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + loop { + match rest.find(fence) { + None => { + out.push_str(rest); + break; + } + Some(start) => { + // Everything before the opening fence. + out.push_str(&rest[..start]); + rest = &rest[start + fence.len()..]; + // Skip optional language tag on the same line. + if let Some(nl) = rest.find('\n') { + rest = &rest[nl + 1..]; + } + // Find the closing fence. + match rest.find(fence) { + None => { + // Unclosed fence — treat rest as omitted. + out.push_str(" code block omitted "); + break; + } + Some(end) => { + out.push_str(" code block omitted "); + rest = &rest[end + fence.len()..]; + // Skip trailing newline after closing fence. + if rest.starts_with('\n') { + rest = &rest[1..]; + } + } + } + } + } + } + out +} + +/// Strip backtick-delimited inline code, leaving the inner text. +/// +/// Single-backtick only — triple backtick already handled above. +fn strip_inline_code(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + loop { + match rest.find('`') { + None => { + out.push_str(rest); + break; + } + Some(start) => { + out.push_str(&rest[..start]); + rest = &rest[start + 1..]; + match rest.find('`') { + None => { + // Unclosed — emit as-is. + out.push_str(rest); + break; + } + Some(end) => { + out.push_str(&rest[..end]); + rest = &rest[end + 1..]; + } + } + } + } + } + out +} + +/// Replace http/https URLs with "link omitted". +/// +/// Trailing sentence-ending punctuation (`.`, `!`, `?`) that immediately follows +/// a URL and is at end-of-string or followed by whitespace is preserved so that +/// sentence splitting and TTS prosody are not degraded. +/// +/// Example: `"See https://x.y/z."` → `"See link omitted."` +fn strip_urls(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut rest = text; + loop { + // Find the earliest URL prefix. + let http = rest.find("http://"); + let https = rest.find("https://"); + let url_start = match (http, https) { + (None, None) => { + out.push_str(rest); + break; + } + (Some(a), None) => a, + (None, Some(b)) => b, + (Some(a), Some(b)) => a.min(b), + }; + out.push_str(&rest[..url_start]); + rest = &rest[url_start..]; + // Consume until whitespace or structural delimiter. + let url_end = rest + .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '"' || c == '\'') + .unwrap_or(rest.len()); + let url_token = &rest[..url_end]; + rest = &rest[url_end..]; + + // Check if the URL token ends with sentence-ending punctuation that + // belongs to the surrounding sentence rather than the URL itself. + // A trailing `.`, `!`, or `?` is preserved when it is at end-of-string + // or followed by whitespace (i.e. it is a sentence boundary). + let trailing_punct = if url_token.ends_with(['.', '!', '?']) { + let after = rest; // rest is already past url_end + if after.is_empty() || after.starts_with(|c: char| c.is_whitespace()) { + // Preserve the trailing punctuation. + &url_token[url_token.len() - 1..] + } else { + "" + } + } else { + "" + }; + + out.push_str("link omitted"); + out.push_str(trailing_punct); + } + out +} + +/// Strip `**`, `*`, `__`, `_emphasis_`, `~~` markdown markers. +/// +/// Underscores are only stripped when they wrap a word (`_text_`). +/// Standalone underscores (e.g. `snake_case` identifiers) are preserved. +fn strip_markdown_markers(text: &str) -> String { + // Order matters: strip multi-char markers before single-char. + let s = text.replace("**", ""); + let s = s.replace("__", ""); + let s = s.replace("~~", ""); + let s = s.replace('*', ""); + strip_underscore_emphasis(&s) +} + +/// Strip `_text_` emphasis markers while preserving underscores in identifiers. +/// +/// A `_` is treated as an emphasis delimiter only when it is preceded by +/// whitespace or the start of the string AND followed by a non-whitespace char, +/// or vice-versa for the closing delimiter. +fn strip_underscore_emphasis(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let chars: Vec = text.chars().collect(); + let len = chars.len(); + let mut i = 0; + while i < len { + if chars[i] == '_' { + // Opening delimiter: preceded by whitespace/start, followed by non-whitespace. + let prev_is_boundary = i == 0 || chars[i - 1].is_whitespace(); + let next_is_nonspace = i + 1 < len && !chars[i + 1].is_whitespace(); + if prev_is_boundary && next_is_nonspace { + // Look for a matching closing `_`. + if let Some(close) = (i + 1..len).find(|&j| { + chars[j] == '_' + && !chars[j - 1].is_whitespace() + && (j + 1 >= len + || chars[j + 1].is_whitespace() + || chars[j + 1].is_ascii_punctuation()) + }) { + // Emit the inner text without the delimiters. + for &ch in &chars[i + 1..close] { + out.push(ch); + } + i = close + 1; + continue; + } + } + // Not an emphasis delimiter — emit as-is. + out.push('_'); + } else { + out.push(chars[i]); + } + i += 1; + } + out +} + +/// Strip Unicode emoji (characters in common emoji ranges). +/// +/// Covers the main Emoji block (U+1F300–U+1FAFF) and supplemental ranges. +/// ASCII emoticons like `:)` are left as-is. +fn strip_emoji(text: &str) -> String { + text.chars().filter(|&c| !is_emoji(c)).collect() +} + +#[inline] +fn is_emoji(c: char) -> bool { + matches!(c, + '\u{1F300}'..='\u{1FAFF}' // Misc symbols, emoticons, transport, etc. + | '\u{2600}'..='\u{27BF}' // Misc symbols, dingbats + | '\u{FE00}'..='\u{FE0F}' // Variation selectors + | '\u{1F000}'..='\u{1F02F}'// Mahjong/domino tiles + | '\u{1F0A0}'..='\u{1F0FF}'// Playing cards + | '\u{200D}' // Zero-width joiner (used in emoji sequences) + | '\u{20E3}' // Combining enclosing keycap + ) +} + +/// Expand numbers to spoken words. +/// +/// Handles: +/// - Times: `HH:MM` → "eleven thirty" +/// - Integers 0–999,999 +/// - Leaves other numeric strings (e.g. "3.14", "1000000+") as-is. +fn expand_numbers(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut chars = text.char_indices().peekable(); + + while let Some((i, c)) = chars.next() { + if c.is_ascii_digit() { + // Collect the full token (digits, colon, dots). + let start = i; + let mut end = i + c.len_utf8(); + while let Some(&(j, nc)) = chars.peek() { + if nc.is_ascii_digit() || nc == ':' || nc == '.' { + end = j + nc.len_utf8(); + chars.next(); + } else { + break; + } + } + let token = &text[start..end]; + out.push_str(&expand_numeric_token(token)); + } else { + out.push(c); + } + } + out +} + +fn expand_numeric_token(token: &str) -> String { + // Strip trailing punctuation that the token collector may have included + // (e.g. "11:30." from "at 11:30.") before attempting to parse. + let token = token.trim_end_matches(|c: char| !c.is_ascii_digit()); + + // Time: HH:MM + if let Some(colon) = token.find(':') { + let h = &token[..colon]; + let m = &token[colon + 1..]; + if let (Ok(hh), Ok(mm)) = (h.parse::(), m.parse::()) { + if hh < 24 && mm < 60 { + let hour_word = int_to_words(hh); + let min_word = if mm == 0 { + String::new() + } else if mm < 10 { + // "9:05" → "nine oh five" (not "nine five") + format!(" oh {}", int_to_words(mm)) + } else { + format!(" {}", int_to_words(mm)) + }; + return format!("{}{}", hour_word, min_word); + } + } + // Not a valid time — return as-is. + return token.to_string(); + } + + // Plain integer 0–999,999. + if token.chars().all(|c| c.is_ascii_digit()) { + if let Ok(n) = token.parse::() { + if n <= 999_999 { + return int_to_words(n); + } + } + } + + // Anything else (decimals, millions+) — leave as-is. + token.to_string() +} + +/// Convert an integer 0–999,999 to English words. +fn int_to_words(n: u32) -> String { + const ONES: &[&str] = &[ + "zero", + "one", + "two", + "three", + "four", + "five", + "six", + "seven", + "eight", + "nine", + "ten", + "eleven", + "twelve", + "thirteen", + "fourteen", + "fifteen", + "sixteen", + "seventeen", + "eighteen", + "nineteen", + ]; + const TENS: &[&str] = &[ + "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety", + ]; + + if n < 20 { + return ONES[n as usize].to_string(); + } + if n < 100 { + let ten = TENS[(n / 10) as usize]; + let one = n % 10; + return if one == 0 { + ten.to_string() + } else { + format!("{} {}", ten, ONES[one as usize]) + }; + } + if n < 1000 { + let hundreds = n / 100; + let remainder = n % 100; + let hundred_word = format!("{} hundred", ONES[hundreds as usize]); + return if remainder == 0 { + hundred_word + } else { + format!("{} {}", hundred_word, int_to_words(remainder)) + }; + } + // 1,000–999,999 + let thousands = n / 1000; + let remainder = n % 1000; + let thousand_word = format!("{} thousand", int_to_words(thousands)); + if remainder == 0 { + thousand_word + } else { + format!("{} {}", thousand_word, int_to_words(remainder)) + } +} + +/// Collapse runs of whitespace (spaces, tabs, newlines) to a single space. +/// Trims leading/trailing whitespace. +fn collapse_whitespace(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + let mut prev_space = true; // Start true to trim leading whitespace. + for c in text.chars() { + if c.is_whitespace() { + if !prev_space { + out.push(' '); + prev_space = true; + } + } else { + out.push(c); + prev_space = false; + } + } + // Trim trailing space. + if out.ends_with(' ') { + out.pop(); + } + out +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// Character budget for greedy synthesis chunks after the latency-critical +/// first sentence. +pub const TTS_CHUNK_MAX_CHARS: usize = 200; + +/// Pocket's output sample rate. +pub const TTS_SAMPLE_RATE: usize = 24_000; +const FADE_OUT_SAMPLES: usize = TTS_SAMPLE_RATE * 8 / 1_000; +const LEAD_IN_SAMPLES: usize = TTS_SAMPLE_RATE * 20 / 1_000; +const TRAILING_SILENCE_SAMPLES: usize = TTS_SAMPLE_RATE * 80 / 1_000; + +/// Convert submitted assistant text into desktop-compatible synthesis chunks. +/// +/// The first sentence stands alone to minimize time to first playback. +/// Remaining sentences are packed greedily up to 200 characters. +pub fn prepare_tts_chunks(raw: &str) -> Vec { + let text = preprocess_for_tts(raw); + if text.is_empty() { + return Vec::new(); + } + let sentences: Vec = split_sentences(&text) + .into_iter() + .filter(|sentence| !sentence.trim().is_empty()) + .collect(); + group_sentences_into_chunks(&sentences, TTS_CHUNK_MAX_CHARS) +} + +/// Group already-prepared sentences using Pocket's desktop chunking policy. +pub fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec { + let mut chunks: Vec = Vec::new(); + for (index, sentence) in sentences.iter().enumerate() { + let sentence = sentence.trim(); + if sentence.is_empty() { + continue; + } + if index == 0 || chunks.is_empty() { + chunks.push(sentence.to_owned()); + continue; + } + let can_merge = chunks.len() > 1 + && chunks + .last() + .is_some_and(|chunk| chunk.len() + 1 + sentence.len() <= max_chars); + if can_merge { + if let Some(chunk) = chunks.last_mut() { + chunk.push(' '); + chunk.push_str(sentence); + } + } else { + chunks.push(sentence.to_owned()); + } + } + chunks +} + +/// Clamp and shape a Pocket chunk for glitch-free playback. +/// +/// Applies the desktop 8 ms fade-out, then prepends 20 ms and appends 80 ms +/// of silence, preserving the established 100 ms inter-chunk gap. +pub fn shape_tts_chunk(samples: Vec) -> Vec { + let mut audio: Vec = samples + .into_iter() + .map(|sample| sample.clamp(-1.0, 1.0)) + .collect(); + let fade = FADE_OUT_SAMPLES.min(audio.len() / 2); + for index in 0..fade { + let sample_index = audio.len() - 1 - index; + audio[sample_index] *= index as f32 / fade as f32; + } + + let mut shaped = Vec::with_capacity(LEAD_IN_SAMPLES + audio.len() + TRAILING_SILENCE_SAMPLES); + shaped.resize(LEAD_IN_SAMPLES, 0.0); + shaped.extend(audio); + shaped.resize(shaped.len() + TRAILING_SILENCE_SAMPLES, 0.0); + shaped +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn strips_fenced_code_block() { + let input = "Here is some code:\n```rust\nfn main() {}\n```\nDone."; + let out = preprocess_for_tts(input); + assert!(out.contains("code block omitted"), "got: {out}"); + assert!(!out.contains("fn main"), "got: {out}"); + } + + #[test] + fn strips_inline_code() { + let out = preprocess_for_tts("Call `foo()` now."); + assert_eq!(out, "Call foo() now."); + } + + #[test] + fn strips_urls() { + let out = preprocess_for_tts("See https://example.com for details."); + assert!(out.contains("link omitted"), "got: {out}"); + assert!(!out.contains("example.com"), "got: {out}"); + } + + #[test] + fn strips_url_preserves_trailing_period() { + // Trailing `.` at end of sentence must be preserved for sentence splitting. + let out = strip_urls("See https://x.y/z."); + assert_eq!(out, "See link omitted.", "got: {out}"); + } + + #[test] + fn strips_url_preserves_trailing_exclamation() { + let out = strip_urls("Visit https://example.com!"); + assert_eq!(out, "Visit link omitted!", "got: {out}"); + } + + #[test] + fn strips_url_preserves_trailing_question() { + let out = strip_urls("Did you see https://example.com?"); + assert_eq!(out, "Did you see link omitted?", "got: {out}"); + } + + #[test] + fn strips_url_mid_sentence_no_punct_preserved() { + // URL in the middle of a sentence — no trailing punct to preserve. + let out = strip_urls("Check https://example.com for more info."); + assert_eq!(out, "Check link omitted for more info.", "got: {out}"); + } + + #[test] + fn strips_bold_italic() { + let out = preprocess_for_tts("**bold** and *italic* and _under_"); + assert_eq!(out, "bold and italic and under"); + } + + #[test] + fn preserves_standalone_underscores() { + // snake_case identifiers should not be mangled. + let out = preprocess_for_tts("call foo_bar() or baz_qux"); + assert!(out.contains("foo_bar"), "got: {out}"); + assert!(out.contains("baz_qux"), "got: {out}"); + } + + #[test] + fn strips_tilde_fenced_block() { + let input = "Here:\n~~~python\nprint('hi')\n~~~\nDone."; + let out = preprocess_for_tts(input); + assert!(out.contains("code block omitted"), "got: {out}"); + assert!(!out.contains("print"), "got: {out}"); + } + + #[test] + fn expands_integers() { + assert_eq!(preprocess_for_tts("42"), "forty two"); + assert_eq!(preprocess_for_tts("0"), "zero"); + assert_eq!(preprocess_for_tts("11"), "eleven"); + assert_eq!(preprocess_for_tts("100"), "one hundred"); + } + + #[test] + fn expands_thousands() { + assert_eq!(preprocess_for_tts("1000"), "one thousand"); + assert_eq!( + preprocess_for_tts("1234"), + "one thousand two hundred thirty four" + ); + assert_eq!(preprocess_for_tts("10000"), "ten thousand"); + assert_eq!(preprocess_for_tts("100000"), "one hundred thousand"); + assert_eq!( + preprocess_for_tts("999999"), + "nine hundred ninety nine thousand nine hundred ninety nine" + ); + } + + #[test] + fn expands_times() { + assert_eq!(preprocess_for_tts("11:30"), "eleven thirty"); + assert_eq!(preprocess_for_tts("9:00"), "nine"); + assert_eq!(preprocess_for_tts("9:05"), "nine oh five"); + assert_eq!(preprocess_for_tts("10:09"), "ten oh nine"); + } + + #[test] + fn collapses_whitespace() { + let out = preprocess_for_tts(" hello world "); + assert_eq!(out, "hello world"); + } + + #[test] + fn split_sentences_basic() { + let result = split_sentences("Hello world. How are you? I'm fine!"); + assert_eq!(result, vec!["Hello world.", "How are you?", "I'm fine!"]); + } + + #[test] + fn split_sentences_newline_break() { + let result = split_sentences("First line.\nSecond line."); + assert_eq!(result, vec!["First line.", "Second line."]); + } + + #[test] + fn split_sentences_em_dash_break() { + let result = split_sentences("Start here—then continue."); + assert_eq!(result, vec!["Start here", "then continue."]); + } + + #[test] + fn split_sentences_abbreviations() { + let result = split_sentences("Dr. Smith went home. He was tired."); + assert_eq!(result, vec!["Dr. Smith went home.", "He was tired."]); + } + + #[test] + fn split_sentences_numbered_list() { + let result = split_sentences("1. First item. 2. Second item."); + // "1." and "2." should NOT cause a split (digit before period). + assert_eq!(result, vec!["1. First item.", "2. Second item."]); + } + + #[test] + fn split_sentences_single() { + let result = split_sentences("Just one sentence"); + assert_eq!(result, vec!["Just one sentence"]); + } + + #[test] + fn split_sentences_empty() { + let result = split_sentences(""); + assert_eq!(result, vec![""]); + } + + #[test] + fn filters_trivial_responses() { + assert_eq!(preprocess_for_tts("."), ""); + assert_eq!(preprocess_for_tts(","), ""); + assert_eq!(preprocess_for_tts("!"), ""); + assert_eq!(preprocess_for_tts(" "), ""); + assert_eq!(preprocess_for_tts("ok"), "ok"); + } + + #[test] + fn full_pipeline() { + let input = + "**Agent says:** check https://relay.example.com at 11:30.\n```\nsome code\n```"; + let out = preprocess_for_tts(input); + assert!(!out.contains("**"), "got: {out}"); + assert!(!out.contains("https://"), "got: {out}"); + assert!(out.contains("eleven thirty"), "got: {out}"); + assert!(out.contains("code block omitted"), "got: {out}"); + } + + #[test] + fn preparation_keeps_first_sentence_latency_chunk_separate() { + assert_eq!( + prepare_tts_chunks("First. Second. Third."), + vec!["First.", "Second. Third."] + ); + } + + #[test] + fn shaping_matches_desktop_padding_clamp_and_fade() { + let shaped = shape_tts_chunk(vec![2.0; 400]); + assert_eq!(shaped.len(), 480 + 400 + 1920); + assert!(shaped[..480].iter().all(|sample| *sample == 0.0)); + assert_eq!(shaped[480], 1.0); + assert_eq!(shaped[879], 0.0); + assert!(shaped[880..].iter().all(|sample| *sample == 0.0)); + } +} diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index c23deef5b2..d0f308e6fc 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1177,6 +1177,7 @@ dependencies = [ "ort", "ort-sys", "rand 0.10.2", + "regex", "sentencepiece-model", "serde", "serde_json", diff --git a/desktop/src-tauri/src/huddle/preprocessing.rs b/desktop/src-tauri/src/huddle/preprocessing.rs index ce85e3145e..ccc3fa0668 100644 --- a/desktop/src-tauri/src/huddle/preprocessing.rs +++ b/desktop/src-tauri/src/huddle/preprocessing.rs @@ -1,667 +1,6 @@ -//! Text preprocessing for TTS output. -//! -//! Mental model: -//! -//! ```text -//! raw agent text -//! → strip fenced code blocks → "code block omitted" -//! → strip inline code → bare text -//! → strip URLs → "link omitted" -//! → strip markdown markers → plain text -//! → strip emoji → (removed) -//! → numbers → words → "forty two" -//! → collapse whitespace → clean string -//! ``` -//! -//! Also provides `split_sentences` — the single sentence-boundary splitter used -//! by both the TTS batching pipeline and the Supertonic text chunker. +//! Compatibility exports for shared TTS preparation. -use regex::Regex; -use std::sync::LazyLock; - -// ── Sentence splitting ──────────────────────────────────────────────────────── - -/// Regex: a sentence-ending punctuation mark followed by whitespace. -static RE_SENTENCE_BOUNDARY: LazyLock = LazyLock::new(|| Regex::new(r"([.!?])\s+").unwrap()); - -/// Common abbreviations that end with a period but are NOT sentence boundaries. -const ABBREVIATIONS: &[&str] = &[ - "Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.", "Jr.", "St.", "Ave.", "Rd.", "Blvd.", "Dept.", - "Inc.", "Ltd.", "Co.", "Corp.", "etc.", "vs.", "i.e.", "e.g.", "Ph.D.", -]; - -/// Split text into sentence-sized chunks. -/// -/// Combines regex-based boundary detection with: -/// - Abbreviation awareness (`Dr.`, `Mr.`, etc. don't split) -/// - Digit-before-period check (avoids splitting `1.` `2.` numbered lists) -/// - `\n` and `—` treated as sentence breaks -/// -/// Returns non-empty, trimmed strings. -pub fn split_sentences(text: &str) -> Vec { - // First, split on newlines and em-dashes to get coarse segments. - let coarse: Vec<&str> = text.split(['\n', '—']).collect(); - - let mut sentences = Vec::new(); - - for segment in coarse { - let segment = segment.trim(); - if segment.is_empty() { - continue; - } - // Within each segment, split on sentence-ending punctuation. - let matches: Vec<_> = RE_SENTENCE_BOUNDARY.find_iter(segment).collect(); - if matches.is_empty() { - sentences.push(segment.to_string()); - continue; - } - - let mut last_end = 0usize; - for m in &matches { - let before = &segment[last_end..m.start()]; - let punc_char = &segment[m.start()..m.start() + 1]; - - // Skip if this looks like an abbreviation. - let combined = format!("{}{}", before.trim(), punc_char); - let is_abbrev = ABBREVIATIONS.iter().any(|a| combined.ends_with(a)); - - // Skip if the character before the period is a digit (numbered list). - let is_digit_period = punc_char == "." - && !before.is_empty() - && before.ends_with(|c: char| c.is_ascii_digit()); - - if !is_abbrev && !is_digit_period { - let piece = segment[last_end..m.end()].trim(); - if !piece.is_empty() { - sentences.push(piece.to_string()); - } - last_end = m.end(); - } - } - - if last_end < segment.len() { - let tail = segment[last_end..].trim(); - if !tail.is_empty() { - sentences.push(tail.to_string()); - } - } - } - - if sentences.is_empty() { - vec![text.to_string()] - } else { - sentences - } -} - -// ── Public API ──────────────────────────────────────────────────────────────── - -/// Prepare `text` for TTS synthesis. -/// -/// Applies in order: -/// 1. Fenced code blocks → "code block omitted" -/// 2. Inline code → bare content (backticks stripped) -/// 3. URLs → "link omitted" -/// 4. Markdown bold/italic/underline markers stripped -/// 5. Emoji stripped -/// 6. Numbers → words (integers 0–999, times HH:MM) -/// 7. Excess whitespace collapsed -pub fn preprocess_for_tts(text: &str) -> String { - let s = strip_fenced_code_blocks(text); - let s = strip_inline_code(&s); - let s = strip_urls(&s); - let s = strip_markdown_markers(&s); - let s = strip_emoji(&s); - let s = expand_numbers(&s); - let s = collapse_whitespace(&s); - // Filter trivially short results — ".", ",", etc. would be spoken as - // "period", "comma" by TTS. Agents that have nothing relevant to say - // should not respond at all, but defense-in-depth catches edge cases. - if s.len() <= 1 { - return String::new(); - } - s -} - -// ── Step implementations ────────────────────────────────────────────────────── - -/// Replace fenced code blocks with "code block omitted". -/// -/// Handles both ` ``` ` and `~~~` fences. Multi-line aware. -fn strip_fenced_code_blocks(text: &str) -> String { - let s = replace_fenced(text, "```"); - replace_fenced(&s, "~~~") -} - -fn replace_fenced(text: &str, fence: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut rest = text; - loop { - match rest.find(fence) { - None => { - out.push_str(rest); - break; - } - Some(start) => { - // Everything before the opening fence. - out.push_str(&rest[..start]); - rest = &rest[start + fence.len()..]; - // Skip optional language tag on the same line. - if let Some(nl) = rest.find('\n') { - rest = &rest[nl + 1..]; - } - // Find the closing fence. - match rest.find(fence) { - None => { - // Unclosed fence — treat rest as omitted. - out.push_str(" code block omitted "); - break; - } - Some(end) => { - out.push_str(" code block omitted "); - rest = &rest[end + fence.len()..]; - // Skip trailing newline after closing fence. - if rest.starts_with('\n') { - rest = &rest[1..]; - } - } - } - } - } - } - out -} - -/// Strip backtick-delimited inline code, leaving the inner text. -/// -/// Single-backtick only — triple backtick already handled above. -fn strip_inline_code(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut rest = text; - loop { - match rest.find('`') { - None => { - out.push_str(rest); - break; - } - Some(start) => { - out.push_str(&rest[..start]); - rest = &rest[start + 1..]; - match rest.find('`') { - None => { - // Unclosed — emit as-is. - out.push_str(rest); - break; - } - Some(end) => { - out.push_str(&rest[..end]); - rest = &rest[end + 1..]; - } - } - } - } - } - out -} - -/// Replace http/https URLs with "link omitted". -/// -/// Trailing sentence-ending punctuation (`.`, `!`, `?`) that immediately follows -/// a URL and is at end-of-string or followed by whitespace is preserved so that -/// sentence splitting and TTS prosody are not degraded. -/// -/// Example: `"See https://x.y/z."` → `"See link omitted."` -fn strip_urls(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut rest = text; - loop { - // Find the earliest URL prefix. - let http = rest.find("http://"); - let https = rest.find("https://"); - let url_start = match (http, https) { - (None, None) => { - out.push_str(rest); - break; - } - (Some(a), None) => a, - (None, Some(b)) => b, - (Some(a), Some(b)) => a.min(b), - }; - out.push_str(&rest[..url_start]); - rest = &rest[url_start..]; - // Consume until whitespace or structural delimiter. - let url_end = rest - .find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '"' || c == '\'') - .unwrap_or(rest.len()); - let url_token = &rest[..url_end]; - rest = &rest[url_end..]; - - // Check if the URL token ends with sentence-ending punctuation that - // belongs to the surrounding sentence rather than the URL itself. - // A trailing `.`, `!`, or `?` is preserved when it is at end-of-string - // or followed by whitespace (i.e. it is a sentence boundary). - let trailing_punct = if url_token.ends_with(['.', '!', '?']) { - let after = rest; // rest is already past url_end - if after.is_empty() || after.starts_with(|c: char| c.is_whitespace()) { - // Preserve the trailing punctuation. - &url_token[url_token.len() - 1..] - } else { - "" - } - } else { - "" - }; - - out.push_str("link omitted"); - out.push_str(trailing_punct); - } - out -} - -/// Strip `**`, `*`, `__`, `_emphasis_`, `~~` markdown markers. -/// -/// Underscores are only stripped when they wrap a word (`_text_`). -/// Standalone underscores (e.g. `snake_case` identifiers) are preserved. -fn strip_markdown_markers(text: &str) -> String { - // Order matters: strip multi-char markers before single-char. - let s = text.replace("**", ""); - let s = s.replace("__", ""); - let s = s.replace("~~", ""); - let s = s.replace('*', ""); - strip_underscore_emphasis(&s) -} - -/// Strip `_text_` emphasis markers while preserving underscores in identifiers. -/// -/// A `_` is treated as an emphasis delimiter only when it is preceded by -/// whitespace or the start of the string AND followed by a non-whitespace char, -/// or vice-versa for the closing delimiter. -fn strip_underscore_emphasis(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let chars: Vec = text.chars().collect(); - let len = chars.len(); - let mut i = 0; - while i < len { - if chars[i] == '_' { - // Opening delimiter: preceded by whitespace/start, followed by non-whitespace. - let prev_is_boundary = i == 0 || chars[i - 1].is_whitespace(); - let next_is_nonspace = i + 1 < len && !chars[i + 1].is_whitespace(); - if prev_is_boundary && next_is_nonspace { - // Look for a matching closing `_`. - if let Some(close) = (i + 1..len).find(|&j| { - chars[j] == '_' - && !chars[j - 1].is_whitespace() - && (j + 1 >= len - || chars[j + 1].is_whitespace() - || chars[j + 1].is_ascii_punctuation()) - }) { - // Emit the inner text without the delimiters. - for &ch in &chars[i + 1..close] { - out.push(ch); - } - i = close + 1; - continue; - } - } - // Not an emphasis delimiter — emit as-is. - out.push('_'); - } else { - out.push(chars[i]); - } - i += 1; - } - out -} - -/// Strip Unicode emoji (characters in common emoji ranges). -/// -/// Covers the main Emoji block (U+1F300–U+1FAFF) and supplemental ranges. -/// ASCII emoticons like `:)` are left as-is. -fn strip_emoji(text: &str) -> String { - text.chars().filter(|&c| !is_emoji(c)).collect() -} - -#[inline] -fn is_emoji(c: char) -> bool { - matches!(c, - '\u{1F300}'..='\u{1FAFF}' // Misc symbols, emoticons, transport, etc. - | '\u{2600}'..='\u{27BF}' // Misc symbols, dingbats - | '\u{FE00}'..='\u{FE0F}' // Variation selectors - | '\u{1F000}'..='\u{1F02F}'// Mahjong/domino tiles - | '\u{1F0A0}'..='\u{1F0FF}'// Playing cards - | '\u{200D}' // Zero-width joiner (used in emoji sequences) - | '\u{20E3}' // Combining enclosing keycap - ) -} - -/// Expand numbers to spoken words. -/// -/// Handles: -/// - Times: `HH:MM` → "eleven thirty" -/// - Integers 0–999,999 -/// - Leaves other numeric strings (e.g. "3.14", "1000000+") as-is. -fn expand_numbers(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut chars = text.char_indices().peekable(); - - while let Some((i, c)) = chars.next() { - if c.is_ascii_digit() { - // Collect the full token (digits, colon, dots). - let start = i; - let mut end = i + c.len_utf8(); - while let Some(&(j, nc)) = chars.peek() { - if nc.is_ascii_digit() || nc == ':' || nc == '.' { - end = j + nc.len_utf8(); - chars.next(); - } else { - break; - } - } - let token = &text[start..end]; - out.push_str(&expand_numeric_token(token)); - } else { - out.push(c); - } - } - out -} - -fn expand_numeric_token(token: &str) -> String { - // Strip trailing punctuation that the token collector may have included - // (e.g. "11:30." from "at 11:30.") before attempting to parse. - let token = token.trim_end_matches(|c: char| !c.is_ascii_digit()); - - // Time: HH:MM - if let Some(colon) = token.find(':') { - let h = &token[..colon]; - let m = &token[colon + 1..]; - if let (Ok(hh), Ok(mm)) = (h.parse::(), m.parse::()) { - if hh < 24 && mm < 60 { - let hour_word = int_to_words(hh); - let min_word = if mm == 0 { - String::new() - } else if mm < 10 { - // "9:05" → "nine oh five" (not "nine five") - format!(" oh {}", int_to_words(mm)) - } else { - format!(" {}", int_to_words(mm)) - }; - return format!("{}{}", hour_word, min_word); - } - } - // Not a valid time — return as-is. - return token.to_string(); - } - - // Plain integer 0–999,999. - if token.chars().all(|c| c.is_ascii_digit()) { - if let Ok(n) = token.parse::() { - if n <= 999_999 { - return int_to_words(n); - } - } - } - - // Anything else (decimals, millions+) — leave as-is. - token.to_string() -} - -/// Convert an integer 0–999,999 to English words. -fn int_to_words(n: u32) -> String { - const ONES: &[&str] = &[ - "zero", - "one", - "two", - "three", - "four", - "five", - "six", - "seven", - "eight", - "nine", - "ten", - "eleven", - "twelve", - "thirteen", - "fourteen", - "fifteen", - "sixteen", - "seventeen", - "eighteen", - "nineteen", - ]; - const TENS: &[&str] = &[ - "", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety", - ]; - - if n < 20 { - return ONES[n as usize].to_string(); - } - if n < 100 { - let ten = TENS[(n / 10) as usize]; - let one = n % 10; - return if one == 0 { - ten.to_string() - } else { - format!("{} {}", ten, ONES[one as usize]) - }; - } - if n < 1000 { - let hundreds = n / 100; - let remainder = n % 100; - let hundred_word = format!("{} hundred", ONES[hundreds as usize]); - return if remainder == 0 { - hundred_word - } else { - format!("{} {}", hundred_word, int_to_words(remainder)) - }; - } - // 1,000–999,999 - let thousands = n / 1000; - let remainder = n % 1000; - let thousand_word = format!("{} thousand", int_to_words(thousands)); - if remainder == 0 { - thousand_word - } else { - format!("{} {}", thousand_word, int_to_words(remainder)) - } -} - -/// Collapse runs of whitespace (spaces, tabs, newlines) to a single space. -/// Trims leading/trailing whitespace. -fn collapse_whitespace(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - let mut prev_space = true; // Start true to trim leading whitespace. - for c in text.chars() { - if c.is_whitespace() { - if !prev_space { - out.push(' '); - prev_space = true; - } - } else { - out.push(c); - prev_space = false; - } - } - // Trim trailing space. - if out.ends_with(' ') { - out.pop(); - } - out -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn strips_fenced_code_block() { - let input = "Here is some code:\n```rust\nfn main() {}\n```\nDone."; - let out = preprocess_for_tts(input); - assert!(out.contains("code block omitted"), "got: {out}"); - assert!(!out.contains("fn main"), "got: {out}"); - } - - #[test] - fn strips_inline_code() { - let out = preprocess_for_tts("Call `foo()` now."); - assert_eq!(out, "Call foo() now."); - } - - #[test] - fn strips_urls() { - let out = preprocess_for_tts("See https://example.com for details."); - assert!(out.contains("link omitted"), "got: {out}"); - assert!(!out.contains("example.com"), "got: {out}"); - } - - #[test] - fn strips_url_preserves_trailing_period() { - // Trailing `.` at end of sentence must be preserved for sentence splitting. - let out = strip_urls("See https://x.y/z."); - assert_eq!(out, "See link omitted.", "got: {out}"); - } - - #[test] - fn strips_url_preserves_trailing_exclamation() { - let out = strip_urls("Visit https://example.com!"); - assert_eq!(out, "Visit link omitted!", "got: {out}"); - } - - #[test] - fn strips_url_preserves_trailing_question() { - let out = strip_urls("Did you see https://example.com?"); - assert_eq!(out, "Did you see link omitted?", "got: {out}"); - } - - #[test] - fn strips_url_mid_sentence_no_punct_preserved() { - // URL in the middle of a sentence — no trailing punct to preserve. - let out = strip_urls("Check https://example.com for more info."); - assert_eq!(out, "Check link omitted for more info.", "got: {out}"); - } - - #[test] - fn strips_bold_italic() { - let out = preprocess_for_tts("**bold** and *italic* and _under_"); - assert_eq!(out, "bold and italic and under"); - } - - #[test] - fn preserves_standalone_underscores() { - // snake_case identifiers should not be mangled. - let out = preprocess_for_tts("call foo_bar() or baz_qux"); - assert!(out.contains("foo_bar"), "got: {out}"); - assert!(out.contains("baz_qux"), "got: {out}"); - } - - #[test] - fn strips_tilde_fenced_block() { - let input = "Here:\n~~~python\nprint('hi')\n~~~\nDone."; - let out = preprocess_for_tts(input); - assert!(out.contains("code block omitted"), "got: {out}"); - assert!(!out.contains("print"), "got: {out}"); - } - - #[test] - fn expands_integers() { - assert_eq!(preprocess_for_tts("42"), "forty two"); - assert_eq!(preprocess_for_tts("0"), "zero"); - assert_eq!(preprocess_for_tts("11"), "eleven"); - assert_eq!(preprocess_for_tts("100"), "one hundred"); - } - - #[test] - fn expands_thousands() { - assert_eq!(preprocess_for_tts("1000"), "one thousand"); - assert_eq!( - preprocess_for_tts("1234"), - "one thousand two hundred thirty four" - ); - assert_eq!(preprocess_for_tts("10000"), "ten thousand"); - assert_eq!(preprocess_for_tts("100000"), "one hundred thousand"); - assert_eq!( - preprocess_for_tts("999999"), - "nine hundred ninety nine thousand nine hundred ninety nine" - ); - } - - #[test] - fn expands_times() { - assert_eq!(preprocess_for_tts("11:30"), "eleven thirty"); - assert_eq!(preprocess_for_tts("9:00"), "nine"); - assert_eq!(preprocess_for_tts("9:05"), "nine oh five"); - assert_eq!(preprocess_for_tts("10:09"), "ten oh nine"); - } - - #[test] - fn collapses_whitespace() { - let out = preprocess_for_tts(" hello world "); - assert_eq!(out, "hello world"); - } - - #[test] - fn split_sentences_basic() { - let result = split_sentences("Hello world. How are you? I'm fine!"); - assert_eq!(result, vec!["Hello world.", "How are you?", "I'm fine!"]); - } - - #[test] - fn split_sentences_newline_break() { - let result = split_sentences("First line.\nSecond line."); - assert_eq!(result, vec!["First line.", "Second line."]); - } - - #[test] - fn split_sentences_em_dash_break() { - let result = split_sentences("Start here—then continue."); - assert_eq!(result, vec!["Start here", "then continue."]); - } - - #[test] - fn split_sentences_abbreviations() { - let result = split_sentences("Dr. Smith went home. He was tired."); - assert_eq!(result, vec!["Dr. Smith went home.", "He was tired."]); - } - - #[test] - fn split_sentences_numbered_list() { - let result = split_sentences("1. First item. 2. Second item."); - // "1." and "2." should NOT cause a split (digit before period). - assert_eq!(result, vec!["1. First item.", "2. Second item."]); - } - - #[test] - fn split_sentences_single() { - let result = split_sentences("Just one sentence"); - assert_eq!(result, vec!["Just one sentence"]); - } - - #[test] - fn split_sentences_empty() { - let result = split_sentences(""); - assert_eq!(result, vec![""]); - } - - #[test] - fn filters_trivial_responses() { - assert_eq!(preprocess_for_tts("."), ""); - assert_eq!(preprocess_for_tts(","), ""); - assert_eq!(preprocess_for_tts("!"), ""); - assert_eq!(preprocess_for_tts(" "), ""); - assert_eq!(preprocess_for_tts("ok"), "ok"); - } - - #[test] - fn full_pipeline() { - let input = - "**Agent says:** check https://relay.example.com at 11:30.\n```\nsome code\n```"; - let out = preprocess_for_tts(input); - assert!(!out.contains("**"), "got: {out}"); - assert!(!out.contains("https://"), "got: {out}"); - assert!(out.contains("eleven thirty"), "got: {out}"); - assert!(out.contains("code block omitted"), "got: {out}"); - } -} +#[allow(unused_imports)] +pub use buzz_voice_pkg::preparation::{ + prepare_tts_chunks, preprocess_for_tts, shape_tts_chunk, split_sentences, +}; diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 63a435cd8e..5f649be786 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -47,7 +47,7 @@ use std::{ }; use super::pocket::{load_text_to_speech, load_voice_style, SAMPLE_RATE, VOICE_FILE_EXT}; -use super::preprocessing::{preprocess_for_tts, split_sentences}; +use super::preprocessing::{prepare_tts_chunks, shape_tts_chunk}; // ── Constants ───────────────────────────────────────────────────────────────── @@ -76,6 +76,7 @@ const SYNTH_STEPS: usize = 1; /// abruptly. **No fade-in is applied** — see `apply_fade_out` for the /// rationale and `examples/pocket_onset_probe.rs` for the measurement that /// motivated removing the leading fade. +#[cfg(test)] const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize; /// Length of the zero-sample cushion prepended before each synthesized @@ -89,6 +90,7 @@ const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize; /// samples is enough to cover a CoreAudio buffer turnover without being audible /// as latency. At sentence boundaries this lead-in is budgeted out of the /// existing inter-sentence pause, so it does not lengthen multi-sentence gaps. +#[cfg(test)] const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; /// Approximate character budget for one synthesis chunk. @@ -108,12 +110,6 @@ const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; /// modestly above upstream's 50, deliberately: erring large means fewer /// seams, and even ~100 tokens is far below the model's 500-LM-step (~40 s) /// ceiling. Do not shrink this budget to chase an exact 50-token match. -const MAX_CHUNK_CHARS: usize = 200; - -/// Silence inserted between sentences by the TTS pipeline (seconds). -/// Injected as a silent buffer between each synthesized sentence chunk. -const INTER_SENTENCE_SILENCE: f32 = 0.1; - // ── Public pipeline handle ──────────────────────────────────────────────────── /// Handle to the running TTS pipeline. @@ -417,7 +413,6 @@ fn tts_worker( // `tts_active` lifecycle: set on the first append while idle, cleared // whenever the player has fully drained — either in the idle timeout // arm or on item receipt before synthesis begins. - let silence_buf_len = (INTER_SENTENCE_SILENCE * SAMPLE_RATE as f32) as usize; // `first_append` = "no audio queued since the player last went idle". // Flipped by `build_sentence_append_buffer` on the first real append; the // idle branch below uses it to decide when to drop `tts_active` and to @@ -485,24 +480,14 @@ fn tts_worker( first_append = true; } - // Preprocess text. - let text = preprocess_for_tts(&raw_text); - if text.is_empty() { - continue; - } - - // Split into sentences, then group into synthesis chunks: the first + // Prepare and group synthesis chunks: the first // sentence stays alone (fast time-to-first-audio), the rest pack // greedily up to MAX_CHUNK_CHARS. Each chunk is one `generate()` // call; playback of chunk N overlaps synthesis of chunk N+1 // (lookahead pipelining). Grouping matches upstream's ~50-token // chunking and halves the exposed prosody seams on multi-sentence // replies — see MAX_CHUNK_CHARS. - let sentences: Vec = split_sentences(&text) - .into_iter() - .filter(|s| !s.trim().is_empty()) - .collect(); - let chunks = group_sentences_into_chunks(&sentences, MAX_CHUNK_CHARS); + let chunks = prepare_tts_chunks(&raw_text); for chunk in &chunks { if handle_cancel_or_shutdown( @@ -523,19 +508,13 @@ fn tts_worker( match engine.synth_chunk(text, "en", &style, SYNTH_STEPS) { Ok(samples) if !samples.is_empty() => { - let mut audio = clamp_to_full_scale(samples); - // Fade-out only — fading-in would attenuate the consonant - // onset (see `apply_fade_out` docstring + the - // 2026-05-18 "first little sound is missing" regression). - apply_fade_out(&mut audio); - // Build one contiguous buffer per synthesized sentence: // lead-in cushion + audio + trailing gap. Keeping this as // a single rodio source preserves the original queue/drain // semantics (one append per sentence) while still giving // every chunk a quiet device warm-up window. - let buf = - build_sentence_append_buffer(&mut first_append, audio, silence_buf_len); + let buf = shape_tts_chunk(samples); + first_append = false; // Check-and-append under `player_ops`, serialized with // the monitor: a barge-in may have arrived during @@ -655,6 +634,7 @@ fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { /// was calibrated on a single anomalously-quiet bench utterance (peak 0.076) /// and clipped 13–34% of samples on real speech ("blown out", 2026-06-12). /// The clamp alone remains as the safety net against outlier transients. +#[cfg(test)] fn clamp_to_full_scale(samples: Vec) -> Vec { samples.into_iter().map(|s| s.clamp(-1.0, 1.0)).collect() } @@ -681,6 +661,7 @@ fn clamp_to_full_scale(samples: Vec) -> Vec { /// no fade-in is needed. The OS audio device gets its quiet ramp-up window /// from `SENTENCE_LEAD_IN_SAMPLES` instead, inserted as pure silence before /// each sentence buffer. +#[cfg(test)] fn apply_fade_out(samples: &mut [f32]) { let len = samples.len(); let fade = FADE_OUT_SAMPLES.min(len / 2); @@ -710,6 +691,7 @@ fn apply_fade_out(samples: &mut [f32]) { /// The worker uses it in the idle branch of the main loop to distinguish /// "never queued anything since last drain" from "drained after speaking", /// which controls when `tts_active` is released and the lead-in re-armed. +#[cfg(test)] fn build_sentence_append_buffer( first_append: &mut bool, audio: Vec, @@ -741,31 +723,9 @@ fn build_sentence_append_buffer( /// Sentences within a chunk are joined with a single space; sentence-ending /// punctuation is preserved by `split_sentences`, so the model sees natural /// multi-sentence prose — the same shape upstream's ~50-token chunker feeds it. +#[cfg(test)] fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec { - let mut chunks: Vec = Vec::new(); - for (i, sentence) in sentences.iter().enumerate() { - let sentence = sentence.trim(); - if sentence.is_empty() { - continue; - } - if i == 0 || chunks.is_empty() { - chunks.push(sentence.to_string()); - continue; - } - // Never merge into the first chunk — it's the latency-critical one. - let can_merge = chunks.len() > 1 - && chunks - .last() - .is_some_and(|c| c.len() + 1 + sentence.len() <= max_chars); - if can_merge { - let last = chunks.last_mut().expect("non-empty checked above"); - last.push(' '); - last.push_str(sentence); - } else { - chunks.push(sentence.to_string()); - } - } - chunks + buzz_voice_pkg::preparation::group_sentences_into_chunks(sentences, max_chars) } // drain_until_shutdown lives in super (huddle/mod.rs) — shared with stt.rs. diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 7d4d95ab9e..3b0b966ece 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -92,6 +92,11 @@ android { versionName = flutter.versionName testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "Buzz") + ndk { + // Native Pocket voice artifacts are built for device arm64 and + // emulator x86_64. Do not publish an installable ABI without them. + abiFilters += listOf("arm64-v8a", "x86_64") + } } sourceSets["main"].jniLibs.srcDir("../../.generated/voice/android/jniLibs") diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt index 9c6e7a1306..d22f2086d3 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt @@ -139,8 +139,9 @@ internal class VoiceAudioOutput( .setBufferSizeInBytes(max(minimum, 32 * 1024)) .build() .also { - check(it.state == AudioTrack.STATE_INITIALIZED) { - "Unable to initialize voice audio." + if (it.state != AudioTrack.STATE_INITIALIZED) { + it.release() + error("Unable to initialize voice audio.") } } } catch (error: Exception) { @@ -149,35 +150,58 @@ internal class VoiceAudioOutput( } track = nextTrack val currentGeneration = generation.incrementAndGet() - nextTrack.play() + try { + nextTrack.play() + } catch (error: Exception) { + track = null + nextTrack.release() + abandonFocus() + throw error + } thread(name = "buzz-pocket-playback") { var offset = 0 - while (offset < pcm.size && generation.get() == currentGeneration) { - val written = nextTrack.write( - pcm, - offset, - pcm.size - offset, - AudioTrack.WRITE_BLOCKING, - ) - if (written <= 0) break - offset += written - } - val complete = offset == pcm.size - if (complete) { - val targetFrames = pcm.size.toLong() / 2 - while (generation.get() == currentGeneration) { - val playedFrames = - runCatching { - nextTrack.playbackHeadPosition.toLong() and 0xFFFF_FFFFL - }.getOrNull() ?: break - if (playedFrames >= targetFrames) break - Thread.sleep(5) + var complete = false + try { + while (offset < pcm.size && generation.get() == currentGeneration) { + val written = nextTrack.write( + pcm, + offset, + pcm.size - offset, + AudioTrack.WRITE_BLOCKING, + ) + if (written <= 0) break + offset += written } + complete = offset == pcm.size + if (complete) { + val targetFrames = pcm.size.toLong() / 2 + val deadlineNanos = + System.nanoTime() + + (targetFrames * 1_000_000_000L / sampleRate) + + 2_000_000_000L + var drained = false + while ( + generation.get() == currentGeneration && + System.nanoTime() < deadlineNanos + ) { + val playedFrames = + runCatching { + nextTrack.playbackHeadPosition.toLong() and 0xFFFF_FFFFL + }.getOrNull() ?: break + if (playedFrames >= targetFrames) { + drained = true + break + } + Thread.sleep(5) + } + complete = drained + } + } catch (_: Exception) { + complete = false } mainHandler.post { if (generation.get() == currentGeneration && track === nextTrack) { - nextTrack.stop() - nextTrack.release() + releaseTrack(nextTrack) track = null abandonFocus() channel.invokeMethod( @@ -196,12 +220,7 @@ internal class VoiceAudioOutput( private fun stop(notify: String?) { if (track == null && !hasFocus) return generation.incrementAndGet() - track?.run { - pause() - flush() - stop() - release() - } + track?.let(::releaseTrack) track = null abandonFocus() if (notify != null) channel.invokeMethod(notify, null) @@ -246,6 +265,13 @@ internal class VoiceAudioOutput( hasFocus = false } + private fun releaseTrack(audioTrack: AudioTrack) { + runCatching { audioTrack.pause() } + runCatching { audioTrack.flush() } + runCatching { audioTrack.stop() } + runCatching { audioTrack.release() } + } + private companion object { const val CHANNEL = "buzz/voice_audio" } diff --git a/mobile/integration_test/pocket_voice_integration_test.dart b/mobile/integration_test/pocket_voice_integration_test.dart index 49525693e3..a3ada5d33d 100644 --- a/mobile/integration_test/pocket_voice_integration_test.dart +++ b/mobile/integration_test/pocket_voice_integration_test.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:buzz/shared/voice/pocket_model_downloader.dart'; import 'package:buzz/shared/voice/pocket_voice_worker.dart'; +import 'package:buzz/shared/voice/voice_audio_output.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; @@ -57,6 +58,18 @@ void main() { first.synthesisTime.inMicroseconds / Duration.microsecondsPerSecond / audioSeconds; + final output = PlatformVoiceAudioOutput(); + final playbackCompleted = output.events.firstWhere( + (event) => event == VoiceAudioEvent.completed, + ); + final playbackClock = Stopwatch()..start(); + await output.play(bytes, first.sampleRate); + await playbackCompleted.timeout(const Duration(seconds: 30)); + playbackClock.stop(); + expect( + playbackClock.elapsedMilliseconds, + greaterThanOrEqualTo((audioSeconds * 800).floor()), + ); final warmClock = Stopwatch()..start(); worker.synthesize(2, 'The engine stays warm for the next response.'); @@ -95,6 +108,7 @@ void main() { 'synthesis_ms=${first.synthesisTime.inMilliseconds} ' 'audio_s=${audioSeconds.toStringAsFixed(3)} ' 'rtf=${rtf.toStringAsFixed(3)} ' + 'playback_ms=${playbackClock.elapsedMilliseconds} ' 'warm_pcm_ms=${warmClock.elapsedMilliseconds} ' 'cancel_ms=${cancelClock.elapsedMilliseconds} ' 'download_ms=${downloadTime?.inMilliseconds ?? 0} ' diff --git a/mobile/ios/Flutter/Voice.xcconfig b/mobile/ios/Flutter/Voice.xcconfig index 8707244f97..ed4fd00bbf 100644 --- a/mobile/ios/Flutter/Voice.xcconfig +++ b/mobile/ios/Flutter/Voice.xcconfig @@ -1,2 +1,2 @@ -BUZZ_VOICE_LIB_DIR = $(SRCROOT)/../.generated/voice/ios/$(PLATFORM_NAME)-arm64 +BUZZ_VOICE_LIB_DIR = $(SRCROOT)/../.generated/voice/ios/$(PLATFORM_NAME)-$(CURRENT_ARCH) OTHER_LDFLAGS = $(inherited) -Wl,-force_load,$(BUZZ_VOICE_LIB_DIR)/libbuzz_voice_mobile.a -lc++ -framework Accelerate -framework CoreML -framework Foundation -framework Metal -framework QuartzCore -framework Security diff --git a/mobile/ios/Runner/VoiceAudioOutput.swift b/mobile/ios/Runner/VoiceAudioOutput.swift index ebd188613e..033a53fa73 100644 --- a/mobile/ios/Runner/VoiceAudioOutput.swift +++ b/mobile/ios/Runner/VoiceAudioOutput.swift @@ -82,17 +82,22 @@ final class VoiceAudioOutput: NSObject, AVAudioPlayerDelegate { let session = AVAudioSession.sharedInstance() try Self.configureAudioSession(session) try session.setActive(true) - let nextPlayer = try AVAudioPlayer(data: Self.waveData(pcm: pcm, sampleRate: sampleRate)) - nextPlayer.delegate = self - nextPlayer.prepareToPlay() - guard nextPlayer.play() else { - throw NSError( - domain: "BuzzVoice", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Unable to start voice playback."] - ) + do { + let nextPlayer = try AVAudioPlayer(data: Self.waveData(pcm: pcm, sampleRate: sampleRate)) + nextPlayer.delegate = self + nextPlayer.prepareToPlay() + guard nextPlayer.play() else { + throw NSError( + domain: "BuzzVoice", + code: 1, + userInfo: [NSLocalizedDescriptionKey: "Unable to start voice playback."] + ) + } + player = nextPlayer + } catch { + try? session.setActive(false, options: [.notifyOthersOnDeactivation]) + throw error } - player = nextPlayer } static func configureAudioSession(_ session: AVAudioSession) throws { @@ -111,6 +116,7 @@ final class VoiceAudioOutput: NSObject, AVAudioPlayerDelegate { } func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + guard self.player === player else { return } self.player = nil try? AVAudioSession.sharedInstance().setActive( false, @@ -119,6 +125,16 @@ final class VoiceAudioOutput: NSObject, AVAudioPlayerDelegate { channel.invokeMethod(flag ? "completed" : "error", arguments: nil) } + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + guard self.player === player else { return } + self.player = nil + try? AVAudioSession.sharedInstance().setActive( + false, + options: [.notifyOthersOnDeactivation] + ) + channel.invokeMethod("error", arguments: error?.localizedDescription) + } + private func observeAudioLifecycle() { let center = NotificationCenter.default observers.append( diff --git a/mobile/lib/features/channels/pocket_voice_conversation.dart b/mobile/lib/features/channels/pocket_voice_conversation.dart index f8c07d3077..bdde9fbe57 100644 --- a/mobile/lib/features/channels/pocket_voice_conversation.dart +++ b/mobile/lib/features/channels/pocket_voice_conversation.dart @@ -20,15 +20,6 @@ class PocketVoiceConversation { for (final event in events) { if (_seen.add(event.id)) unseen.add(event); } - if (_seen.length > 1024) { - final retained = events.reversed - .take(512) - .map((event) => event.id) - .toSet(); - _seen - ..clear() - ..addAll(retained); - } if (!_initialized) { _initialized = true; return const []; diff --git a/mobile/lib/shared/voice/pocket_model_downloader.dart b/mobile/lib/shared/voice/pocket_model_downloader.dart index 60cbdb1ad8..72171f849c 100644 --- a/mobile/lib/shared/voice/pocket_model_downloader.dart +++ b/mobile/lib/shared/voice/pocket_model_downloader.dart @@ -14,6 +14,8 @@ import 'pocket_model_manifest.dart'; const _manifestName = '.buzz-model-manifest'; const _storageChannel = MethodChannel('buzz/voice_audio'); const _minimumReserveBytes = 64 * 1024 * 1024; +const _defaultSendTimeout = Duration(seconds: 30); +const _defaultBodyIdleTimeout = Duration(seconds: 30); class PocketDownloadCancelled implements Exception { const PocketDownloadCancelled(); @@ -46,6 +48,8 @@ class PocketModelDownloader { final Future Function(String path) excludeFromBackup; final List artifacts; final String installationId; + final Duration sendTimeout; + final Duration bodyIdleTimeout; http.Client? _client; bool _cancelled = false; @@ -57,6 +61,8 @@ class PocketModelDownloader { Future Function(String path)? excludeFromBackup, this.artifacts = const [], String? installationId, + this.sendTimeout = _defaultSendTimeout, + this.bodyIdleTimeout = _defaultBodyIdleTimeout, }) : clientFactory = clientFactory ?? http.Client.new, applicationSupportDirectory = applicationSupportDirectory ?? getApplicationSupportDirectory, @@ -125,6 +131,7 @@ class PocketModelDownloader { '${parent.path}/.pocket-tts-v$pocketModelVersion.install-$installationId', ); await staging.create(recursive: true); + await excludeFromBackup(staging.path); var downloaded = 0; _client = clientFactory(); try { @@ -212,7 +219,7 @@ class PocketModelDownloader { if (await part.exists()) await part.delete(); try { final request = http.Request('GET', artifact.url); - final response = await _client!.send(request); + final response = await _client!.send(request).timeout(sendTimeout); if (response.statusCode != HttpStatus.ok) { final retryable = response.statusCode == 408 || @@ -236,7 +243,7 @@ class PocketModelDownloader { final sink = part.openWrite(); var bytes = 0; try { - await for (final chunk in response.stream) { + await for (final chunk in response.stream.timeout(bodyIdleTimeout)) { _throwIfCancelled(); bytes += chunk.length; if (bytes > artifact.size) { @@ -282,6 +289,10 @@ class PocketModelDownloader { error is TimeoutException || (error is PocketModelDownloadException && error.retryable); if (!retryable || attempt == 2) break; + if (error is TimeoutException || error is SocketException) { + _client?.close(); + _client = clientFactory(); + } await Future.delayed(Duration(milliseconds: 250 << attempt)); } } diff --git a/mobile/lib/shared/voice/pocket_model_manifest.dart b/mobile/lib/shared/voice/pocket_model_manifest.dart index 0445d9fc6b..971bc81f78 100644 --- a/mobile/lib/shared/voice/pocket_model_manifest.dart +++ b/mobile/lib/shared/voice/pocket_model_manifest.dart @@ -17,7 +17,6 @@ class PocketModelArtifact { const pocketModelVersion = '3'; const pocketModelRuntimeBytes = 473251634; -const pocketModelDownloadBytes = 473270289; const _pocketModelBase = 'https://huggingface.co/csukuangfj2/' 'sherpa-onnx-pocket-tts-2026-01-26/resolve/' @@ -91,13 +90,26 @@ Pocket TTS Licensed under the Creative Commons Attribution 4.0 International License (CC-BY-4.0). License text: https://creativecommons.org/licenses/by/4.0/ -Original model: https://huggingface.co/kyutai/pocket-tts -ONNX export: https://huggingface.co/KevinAHM/pocket-tts-onnx -Sherpa-onnx repackage: +Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts +Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926). +Mimi neural codec by Kyutai is bundled as part of the model. + +ONNX export by KevinAHM: https://huggingface.co/KevinAHM/pocket-tts-onnx +Sherpa-onnx repackage by csukuangfj / k2-fsa: https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26 -Reference voice: Mary (VCTK p333, enhanced by ai-coustics), from -https://huggingface.co/kyutai/tts-voices +Bundled reference voice (reference_sample.wav): +"Mary (f, conversation)" preset from the Kyutai TTS demo voice catalogue +(https://kyutai.org/tts), distributed via +https://huggingface.co/kyutai/tts-voices as `vctk/p333_023_enhanced.wav`. +Original recording from the Voice Cloning Toolkit (VCTK) corpus, speaker p333: +https://datashare.ed.ac.uk/handle/10283/3443 (CC-BY-4.0). +Recording enhancement (denoise/dereverb) by ai-coustics: +https://ai-coustics.com/ + +Buzz ships all ONNX/model artifacts and the reference voice WAV unmodified, +renamed only by placement in the local model directory. -Provided "AS IS", without warranty of any kind, express or implied. +Provided "AS IS", without warranty of any kind, express or implied. See the +license text for full warranty disclaimer. '''; diff --git a/mobile/lib/shared/voice/pocket_model_provider.dart b/mobile/lib/shared/voice/pocket_model_provider.dart index 37aad14051..98668d297d 100644 --- a/mobile/lib/shared/voice/pocket_model_provider.dart +++ b/mobile/lib/shared/voice/pocket_model_provider.dart @@ -21,21 +21,15 @@ class PocketModelState { final PocketModelPhase phase; final int downloaded; final int total; - final String? currentFile; final String? path; final String? message; - final int? requiredBytes; - final int? availableBytes; const PocketModelState({ required this.phase, this.downloaded = 0, this.total = 0, - this.currentFile, this.path, this.message, - this.requiredBytes, - this.availableBytes, }); double get progress => total == 0 ? 0 : downloaded / total; @@ -78,12 +72,11 @@ class PocketModelNotifier extends Notifier { final downloader = ref.read(pocketModelDownloaderProvider); _active = downloader; try { - final directory = await downloader.install((downloaded, total, file) { + final directory = await downloader.install((downloaded, total, _) { state = PocketModelState( phase: PocketModelPhase.downloading, downloaded: downloaded, total: total, - currentFile: file, ); }); state = const PocketModelState(phase: PocketModelPhase.verifying); @@ -98,11 +91,9 @@ class PocketModelNotifier extends Notifier { ); } on PocketDownloadCancelled { state = const PocketModelState(phase: PocketModelPhase.cancelled); - } on PocketInsufficientSpace catch (error) { + } on PocketInsufficientSpace { state = PocketModelState( phase: PocketModelPhase.insufficientSpace, - requiredBytes: error.required, - availableBytes: error.available, message: 'Not enough free space for Pocket voice.', ); } on FileSystemException catch (error) { @@ -123,6 +114,4 @@ class PocketModelNotifier extends Notifier { } void cancel() => _active?.cancel(); - - Future retry() => download(); } diff --git a/mobile/lib/shared/voice/pocket_voice_controller.dart b/mobile/lib/shared/voice/pocket_voice_controller.dart index 5f2ba9eb54..96ad61ea43 100644 --- a/mobile/lib/shared/voice/pocket_voice_controller.dart +++ b/mobile/lib/shared/voice/pocket_voice_controller.dart @@ -1,12 +1,13 @@ import 'dart:async'; +import 'dart:collection'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../relay/relay.dart'; import 'pocket_model_provider.dart'; import 'pocket_voice_worker.dart'; import 'voice_audio_output.dart'; -import '../relay/relay.dart'; enum PocketVoicePhase { off, loading, listening, synthesizing, speaking, error } @@ -15,17 +16,11 @@ class PocketVoiceState { final PocketVoicePhase phase; final String? conversationKey; final String? error; - final Duration? synthesisTime; - final Duration? timeToPlayback; - final double? realTimeFactor; const PocketVoiceState({ this.phase = PocketVoicePhase.off, this.conversationKey, this.error, - this.synthesisTime, - this.timeToPlayback, - this.realTimeFactor, }); bool get enabled => phase != PocketVoicePhase.off; @@ -35,41 +30,45 @@ final voiceAudioOutputProvider = Provider( (_) => PlatformVoiceAudioOutput(), ); +final pocketVoiceWorkerFactoryProvider = Provider( + (_) => PocketVoiceWorker.new, +); + final pocketVoiceProvider = NotifierProvider( PocketVoiceNotifier.new, ); class PocketVoiceNotifier extends Notifier { + final Queue _utterances = Queue(); + final Queue _audio = Queue(); PocketVoiceWorker? _worker; + Future? _workerStart; StreamSubscription? _workerSubscription; StreamSubscription? _audioSubscription; - int _generation = 0; - Stopwatch? _playbackClock; + int _transitionEpoch = 0; + int _nextGeneration = 0; + int? _activeGeneration; + bool _synthesisComplete = false; + bool _playbackActive = false; + bool _stopping = false; + bool _queueWhileStopping = false; + Future? _stopFuture; @override PocketVoiceState build() { ref.listen(relayConfigProvider, (previous, _) { if (previous != null) unawaited(disable()); }); - _audioSubscription = ref.read(voiceAudioOutputProvider).events.listen(( - event, - ) { - if (event != VoiceAudioEvent.completed) { - unawaited(interrupt()); - } else if (state.enabled) { - state = PocketVoiceState( - phase: PocketVoicePhase.listening, - conversationKey: state.conversationKey, - synthesisTime: state.synthesisTime, - timeToPlayback: state.timeToPlayback, - realTimeFactor: state.realTimeFactor, - ); - } - }); + _audioSubscription = ref + .read(voiceAudioOutputProvider) + .events + .listen(_handleAudioEvent); ref.onDispose(() { + _transitionEpoch += 1; _workerSubscription?.cancel(); _audioSubscription?.cancel(); + _worker?.cancel(); unawaited(_worker?.dispose()); }); return const PocketVoiceState(); @@ -77,7 +76,10 @@ class PocketVoiceNotifier extends Notifier { Future enable(String conversationKey) async { if (state.conversationKey == conversationKey && state.enabled) return; - await disable(); + final epoch = ++_transitionEpoch; + await _stopConversation(preserveIncoming: false); + if (epoch != _transitionEpoch) return; + final model = ref.read(pocketModelProvider); if (model.phase != PocketModelPhase.ready || model.path == null) { throw StateError('Download Pocket voice before starting a conversation.'); @@ -86,115 +88,250 @@ class PocketVoiceNotifier extends Notifier { phase: PocketVoicePhase.loading, conversationKey: conversationKey, ); - final worker = PocketVoiceWorker(); - _worker = worker; - _workerSubscription = worker.responses.listen(_handleWorkerResponse); try { - await worker.start(model.path!); + await _ensureWorker(model.path!); + if (epoch != _transitionEpoch) return; state = PocketVoiceState( phase: PocketVoicePhase.listening, conversationKey: conversationKey, ); + _startNextUtterance(); } catch (error) { - state = PocketVoiceState( - phase: PocketVoicePhase.error, - conversationKey: conversationKey, - error: error.toString(), - ); + if (epoch == _transitionEpoch) { + state = PocketVoiceState( + phase: PocketVoicePhase.error, + conversationKey: conversationKey, + error: error.toString(), + ); + } rethrow; } } Future disable() async { - _generation += 1; - await ref.read(voiceAudioOutputProvider).stop(); - _worker?.cancel(); - await _workerSubscription?.cancel(); - _workerSubscription = null; - await _worker?.dispose(); - _worker = null; + _transitionEpoch += 1; state = const PocketVoiceState(); + await _stopConversation(preserveIncoming: false); } void speak(String conversationKey, String text) { if (!state.enabled || state.conversationKey != conversationKey) return; + if (state.phase == PocketVoicePhase.error) return; + if (_stopping && !_queueWhileStopping) return; final trimmed = text.trim(); if (trimmed.length <= 1 || trimmed.startsWith('[System]')) return; - final bounded = String.fromCharCodes(trimmed.runes.take(2000)); - _generation += 1; - _playbackClock = Stopwatch()..start(); - state = PocketVoiceState( - phase: PocketVoicePhase.synthesizing, - conversationKey: conversationKey, - ); - _worker?.synthesize(_generation, bounded); + _utterances.add(trimmed); + _startNextUtterance(); } Future interrupt() async { - _generation += 1; - _worker?.cancel(); - await ref.read(voiceAudioOutputProvider).stop(); - if (state.enabled) { + final epoch = ++_transitionEpoch; + await _stopConversation(preserveIncoming: true); + if (epoch == _transitionEpoch && state.enabled) { state = PocketVoiceState( phase: PocketVoicePhase.listening, conversationKey: state.conversationKey, ); + _startNextUtterance(); + } + } + + Future _ensureWorker(String modelPath) { + final worker = _worker; + if (worker != null && worker.isReady) return Future.value(worker); + final starting = _workerStart; + if (starting != null) return starting; + + final created = ref.read(pocketVoiceWorkerFactoryProvider)(); + _worker = created; + _workerSubscription = created.responses.listen(_handleWorkerResponse); + final future = created.start(modelPath).then((_) => created); + _workerStart = future; + return () async { + try { + return await future; + } catch (error, stackTrace) { + if (identical(_worker, created)) { + _worker = null; + await _workerSubscription?.cancel(); + _workerSubscription = null; + } + await created.dispose(); + Error.throwWithStackTrace(error, stackTrace); + } finally { + if (identical(_workerStart, future)) _workerStart = null; + } + }(); + } + + Future _stopConversation({required bool preserveIncoming}) async { + final activeStop = _stopFuture; + if (activeStop != null) { + if (!preserveIncoming) { + _queueWhileStopping = false; + _utterances.clear(); + } + await activeStop; + return; + } + _stopping = true; + _queueWhileStopping = preserveIncoming; + _utterances.clear(); + _audio.clear(); + _activeGeneration = null; + _synthesisComplete = false; + _playbackActive = false; + final output = ref.read(voiceAudioOutputProvider); + final worker = _worker; + worker?.cancel(); + final stop = Future.wait([ + output.stop(), + if (worker != null) worker.cancelAndWait(), + ]); + _stopFuture = stop; + try { + await stop; + } finally { + if (identical(_stopFuture, stop)) { + _stopFuture = null; + _stopping = false; + _queueWhileStopping = false; + } + } + } + + void _startNextUtterance() { + if (_stopping || + _activeGeneration != null || + _utterances.isEmpty || + !state.enabled) { + return; + } + final worker = _worker; + if (worker == null || !worker.isReady) return; + final utterance = _utterances.removeFirst(); + final generation = ++_nextGeneration; + _activeGeneration = generation; + _synthesisComplete = false; + state = PocketVoiceState( + phase: PocketVoicePhase.synthesizing, + conversationKey: state.conversationKey, + ); + try { + worker.synthesize(generation, utterance); + } catch (error) { + _activeGeneration = null; + state = PocketVoiceState( + phase: PocketVoicePhase.error, + conversationKey: state.conversationKey, + error: error.toString(), + ); } } - Future _handleWorkerResponse(PocketWorkerResponse response) async { + void _handleWorkerResponse(PocketWorkerResponse response) { switch (response) { case PocketWorkerReady(): - return; case PocketWorkerStopped(): return; + case PocketWorkerDone(): + if (response.generation != _activeGeneration) return; + _synthesisComplete = true; + if (!_playbackActive && _audio.isEmpty) _finishUtterance(); case PocketWorkerFailure(): - if (response.generation != null && response.generation != _generation) { - return; - } - state = PocketVoiceState( - phase: PocketVoicePhase.error, - conversationKey: state.conversationKey, - error: response.message, - ); + if (response.generation != _activeGeneration) return; + _failPlayback(response.message); case PocketWorkerAudio(): - if (response.generation != _generation) return; - final generation = response.generation; - final conversationKey = state.conversationKey; - final bytes = response.data.materialize().asUint8List(); - final audioSeconds = bytes.length / 2 / response.sampleRate; - final rtf = audioSeconds == 0 - ? null - : response.synthesisTime.inMicroseconds / - Duration.microsecondsPerSecond / - audioSeconds; - final output = ref.read(voiceAudioOutputProvider); - try { - await output.play(Uint8List.fromList(bytes), response.sampleRate); - } catch (error) { - if (generation == _generation && state.enabled) { - state = PocketVoiceState( - phase: PocketVoicePhase.error, - conversationKey: state.conversationKey, - error: error.toString(), - ); - } - return; - } - if (generation != _generation || - !state.enabled || - state.conversationKey != conversationKey) { - await output.stop(); - return; + if (response.generation != _activeGeneration) return; + _audio.add(response); + if (response.isLast) _synthesisComplete = true; + unawaited(_playNextChunk()); + } + } + + Future _playNextChunk() async { + if (_playbackActive || _audio.isEmpty || _activeGeneration == null) return; + final activeGeneration = _activeGeneration; + final conversationKey = state.conversationKey; + final chunk = _audio.removeFirst(); + _playbackActive = true; + final output = ref.read(voiceAudioOutputProvider); + try { + await output.play( + chunk.data.materialize().asUint8List(), + chunk.sampleRate, + ); + } catch (error) { + if (activeGeneration == _activeGeneration && state.enabled) { + _failPlayback(error.toString()); + } + return; + } + if (activeGeneration != _activeGeneration || + !state.enabled || + state.conversationKey != conversationKey) { + _playbackActive = false; + await output.stop(); + return; + } + state = PocketVoiceState( + phase: PocketVoicePhase.speaking, + conversationKey: state.conversationKey, + ); + } + + void _handleAudioEvent(VoiceAudioEvent event) { + switch (event) { + case VoiceAudioEvent.completed: + if (!_playbackActive) return; + _playbackActive = false; + if (_audio.isNotEmpty) { + unawaited(_playNextChunk()); + } else if (_synthesisComplete) { + _finishUtterance(); + } else if (state.enabled) { + state = PocketVoiceState( + phase: PocketVoicePhase.synthesizing, + conversationKey: state.conversationKey, + ); } - _playbackClock?.stop(); - state = PocketVoiceState( - phase: PocketVoicePhase.speaking, - conversationKey: state.conversationKey, - synthesisTime: response.synthesisTime, - timeToPlayback: _playbackClock?.elapsed, - realTimeFactor: rtf, - ); + case VoiceAudioEvent.error: + if (_playbackActive) _failPlayback('Pocket voice playback failed.'); + case VoiceAudioEvent.interrupted: + case VoiceAudioEvent.routeLost: + case VoiceAudioEvent.backgrounded: + unawaited(interrupt()); + } + } + + void _failPlayback(String message) { + _worker?.cancel(); + _utterances.clear(); + _activeGeneration = null; + _audio.clear(); + _synthesisComplete = false; + _playbackActive = false; + unawaited(ref.read(voiceAudioOutputProvider).stop()); + if (state.enabled) { + state = PocketVoiceState( + phase: PocketVoicePhase.error, + conversationKey: state.conversationKey, + error: message, + ); + } + } + + void _finishUtterance() { + _activeGeneration = null; + _synthesisComplete = false; + _playbackActive = false; + if (_utterances.isNotEmpty) { + _startNextUtterance(); + } else if (state.enabled) { + state = PocketVoiceState( + phase: PocketVoicePhase.listening, + conversationKey: state.conversationKey, + ); } } } diff --git a/mobile/lib/shared/voice/pocket_voice_ffi.dart b/mobile/lib/shared/voice/pocket_voice_ffi.dart index 1869647707..db9491c06e 100644 --- a/mobile/lib/shared/voice/pocket_voice_ffi.dart +++ b/mobile/lib/shared/voice/pocket_voice_ffi.dart @@ -1,3 +1,4 @@ +import 'dart:convert'; import 'dart:ffi' as ffi; import 'dart:io'; import 'dart:typed_data'; @@ -29,6 +30,10 @@ typedef _SynthDart = _PcmResult Function(ffi.Pointer, ffi.Pointer); typedef _CancelNative = ffi.Void Function(ffi.Pointer); typedef _CancelDart = void Function(ffi.Pointer); +typedef _ResetCancelNative = ffi.Void Function(ffi.Pointer); +typedef _ResetCancelDart = void Function(ffi.Pointer); +typedef _PrepareNative = ffi.Pointer Function(ffi.Pointer); +typedef _PrepareDart = ffi.Pointer Function(ffi.Pointer); typedef _DestroyNative = ffi.Void Function(ffi.Pointer); typedef _DestroyDart = void Function(ffi.Pointer); typedef _FreePcmNative = ffi.Void Function(_PcmResult); @@ -48,6 +53,8 @@ class PocketVoiceFfi { late final _CreateDart _create; late final _SynthDart _synthesize; late final _CancelDart _cancel; + late final _ResetCancelDart _resetCancel; + late final _PrepareDart _prepare; late final _DestroyDart _destroy; late final _FreePcmDart _freePcm; late final _FreeStringDart _freeString; @@ -62,6 +69,13 @@ class PocketVoiceFfi { _cancel = _library.lookupFunction<_CancelNative, _CancelDart>( 'buzz_voice_engine_cancel', ); + _resetCancel = _library + .lookupFunction<_ResetCancelNative, _ResetCancelDart>( + 'buzz_voice_engine_reset_cancel', + ); + _prepare = _library.lookupFunction<_PrepareNative, _PrepareDart>( + 'buzz_voice_prepare_chunks_json', + ); _destroy = _library.lookupFunction<_DestroyNative, _DestroyDart>( 'buzz_voice_engine_destroy', ); @@ -111,6 +125,24 @@ class PocketVoiceFfi { void cancel(int handle) => _cancel(ffi.Pointer.fromAddress(handle)); + void resetCancel(int handle) => + _resetCancel(ffi.Pointer.fromAddress(handle)); + + List prepareChunks(String text) { + final input = text.toNativeUtf8(); + final result = _prepare(input); + calloc.free(input); + if (result == ffi.nullptr) { + throw StateError('Pocket text preparation returned no result.'); + } + final encoded = _takeError(result); + final decoded = jsonDecode(encoded); + if (decoded is! List) { + throw StateError('Pocket text preparation returned invalid JSON.'); + } + return decoded.cast(); + } + void destroy(int handle) => _destroy(ffi.Pointer.fromAddress(handle)); diff --git a/mobile/lib/shared/voice/pocket_voice_worker.dart b/mobile/lib/shared/voice/pocket_voice_worker.dart index bbdfb61aae..2cb60f11fe 100644 --- a/mobile/lib/shared/voice/pocket_voice_worker.dart +++ b/mobile/lib/shared/voice/pocket_voice_worker.dart @@ -18,15 +18,23 @@ class PocketWorkerAudio extends PocketWorkerResponse { final TransferableTypedData data; final int sampleRate; final Duration synthesisTime; + final bool isLast; const PocketWorkerAudio({ required this.generation, required this.data, required this.sampleRate, required this.synthesisTime, + required this.isLast, }); } +class PocketWorkerDone extends PocketWorkerResponse { + final int generation; + + const PocketWorkerDone(this.generation); +} + class PocketWorkerFailure extends PocketWorkerResponse { final int? generation; final String message; @@ -61,9 +69,11 @@ class PocketVoiceWorker { int? _handle; PocketVoiceFfi? _mainFfi; ReceivePort? _receive; + Completer? _activeSynthesis; + int? _activeGeneration; Stream get responses => _responses.stream; - int? get handle => _handle; + bool get isReady => _commands != null; Future start(String modelPath) async { if (_isolate != null) return; @@ -84,18 +94,33 @@ class PocketVoiceWorker { _isolate = null; receive.close(); _receive = null; + _commands = null; throw StateError((first.$2 as PocketWorkerFailure).message); } final ready = first.$2 as PocketWorkerReady; _handle = ready.handle; _mainFfi = PocketVoiceFfi(); messages.listen((message) { - if (message is PocketWorkerResponse) _responses.add(message); + if (message is! PocketWorkerResponse) return; + if (_finishesActiveSynthesis(message)) { + _activeSynthesis?.complete(); + _activeSynthesis = null; + _activeGeneration = null; + } + _responses.add(message); }); } void synthesize(int generation, String text) { - _commands?.send(_Synthesize(generation, text)); + if (_commands == null) { + throw StateError('Pocket worker is not ready.'); + } + if (_activeSynthesis != null) { + throw StateError('Pocket worker already has an active synthesis.'); + } + _activeGeneration = generation; + _activeSynthesis = Completer(); + _commands!.send(_Synthesize(generation, text)); } void cancel() { @@ -103,23 +128,26 @@ class PocketVoiceWorker { if (handle != null) _mainFfi?.cancel(handle); } + Future cancelAndWait() async { + final active = _activeSynthesis; + if (active == null) return; + cancel(); + await active.future; + } + Future dispose() async { if (_responses.isClosed) return; - cancel(); - final stopped = _responses.stream.firstWhere( - (response) => response is PocketWorkerStopped, - ); + await cancelAndWait(); final commands = _commands; _commands = null; _handle = null; _mainFfi = null; - commands?.send(const _Dispose()); if (commands != null) { - try { - await stopped.timeout(const Duration(seconds: 10)); - } on TimeoutException { - _isolate?.kill(); - } + final stopped = _responses.stream.firstWhere( + (response) => response is PocketWorkerStopped, + ); + commands.send(const _Dispose()); + await stopped; } _isolate?.kill(); _isolate = null; @@ -127,6 +155,18 @@ class PocketVoiceWorker { _receive = null; await _responses.close(); } + + bool _finishesActiveSynthesis(PocketWorkerResponse response) { + final activeGeneration = _activeGeneration; + if (activeGeneration == null) return false; + return switch (response) { + PocketWorkerAudio(:final generation, :final isLast) => + generation == activeGeneration && isLast, + PocketWorkerDone(:final generation) => generation == activeGeneration, + PocketWorkerFailure(:final generation) => generation == activeGeneration, + _ => false, + }; + } } void _workerMain((SendPort, String) startup) { @@ -145,18 +185,27 @@ void _workerMain((SendPort, String) startup) { commands.listen((command) { switch (command) { case _Synthesize(): - final stopwatch = Stopwatch()..start(); try { - final pcm = ffi.synthesize(handle, command.text); - stopwatch.stop(); - output.send( - PocketWorkerAudio( - generation: command.generation, - data: TransferableTypedData.fromList([pcm.bytes]), - sampleRate: pcm.sampleRate, - synthesisTime: stopwatch.elapsed, - ), - ); + final chunks = ffi.prepareChunks(command.text); + if (chunks.isEmpty) { + output.send(PocketWorkerDone(command.generation)); + return; + } + ffi.resetCancel(handle); + for (var index = 0; index < chunks.length; index += 1) { + final stopwatch = Stopwatch()..start(); + final pcm = ffi.synthesize(handle, chunks[index]); + stopwatch.stop(); + output.send( + PocketWorkerAudio( + generation: command.generation, + data: TransferableTypedData.fromList([pcm.bytes]), + sampleRate: pcm.sampleRate, + synthesisTime: stopwatch.elapsed, + isLast: index == chunks.length - 1, + ), + ); + } } catch (error) { output.send( PocketWorkerFailure( diff --git a/mobile/test/features/channels/pocket_voice_conversation_test.dart b/mobile/test/features/channels/pocket_voice_conversation_test.dart index f68f92b3a1..8c5fa8610f 100644 --- a/mobile/test/features/channels/pocket_voice_conversation_test.dart +++ b/mobile/test/features/channels/pocket_voice_conversation_test.dart @@ -108,6 +108,23 @@ void main() { expect(spoken.map((event) => event.id), ['direct']); }); + + test('never re-speaks retained history after large conversation updates', () { + final conversation = PocketVoiceConversation(); + final history = [ + for (var index = 0; index < 1100; index += 1) + _event('history-$index', 'bot', 'Historical response $index'), + ]; + conversation.update(events: history, members: [bot], currentPubkey: 'self'); + + final spoken = conversation.update( + events: [...history, _event('new', 'bot', 'New response')], + members: [bot], + currentPubkey: 'self', + ); + + expect(spoken.map((event) => event.id), ['new']); + }); } NostrEvent _event( diff --git a/mobile/test/shared/voice/pocket_model_downloader_test.dart b/mobile/test/shared/voice/pocket_model_downloader_test.dart index bebd73e654..c2dc5407d7 100644 --- a/mobile/test/shared/voice/pocket_model_downloader_test.dart +++ b/mobile/test/shared/voice/pocket_model_downloader_test.dart @@ -1,5 +1,5 @@ -import 'dart:io'; import 'dart:async'; +import 'dart:io'; import 'package:buzz/shared/voice/pocket_model_downloader.dart'; import 'package:buzz/shared/voice/pocket_model_manifest.dart'; @@ -46,7 +46,12 @@ void main() { expect(await downloader.verify(directory, hashContents: true), isTrue); expect(await File('${directory.path}/tiny.bin').readAsString(), 'hello'); expect(progress.last, (5, 5, 'tiny.bin')); - expect(excluded, [directory.path]); + expect(excluded, hasLength(2)); + expect( + excluded.first, + contains('.pocket-tts-v$pocketModelVersion.install-'), + ); + expect(excluded.last, directory.path); expect( await directory.parent .list() @@ -133,21 +138,84 @@ void main() { downloader.cancel(); await expectLater(install, throwsA(isA())); }); + + test('replaces clients and retries when sending never completes', () async { + final clients = <_NeverSendingClient>[]; + final downloader = _downloader( + temporary, + artifact, + clientFactory: () { + final client = _NeverSendingClient(); + clients.add(client); + return client; + }, + sendTimeout: const Duration(milliseconds: 5), + ); + + await expectLater( + downloader.install((_, _, _) {}), + throwsA(isA()), + ); + + expect(clients, hasLength(3)); + expect(clients.every((client) => client.closed), isTrue); + }); + + test('replaces the client and retries after a body idle timeout', () async { + final stalled = _StallingBodyClient(); + final succeeding = MockClient((_) async => http.Response('hello', 200)); + final clients = [stalled, succeeding]; + var nextClient = 0; + final downloader = _downloader( + temporary, + artifact, + clientFactory: () => clients[nextClient++], + bodyIdleTimeout: const Duration(milliseconds: 5), + ); + + final directory = await downloader.install((_, _, _) {}); + + expect(await File('${directory.path}/tiny.bin').readAsString(), 'hello'); + expect(stalled.closed, isTrue); + expect(nextClient, 2); + }); + + test('license attribution stays identical to the desktop canonical text', () { + final current = Directory.current; + final repository = current.path.endsWith('${Platform.pathSeparator}mobile') + ? current.parent + : current; + final desktopModels = File( + '${repository.path}/desktop/src-tauri/src/huddle/models.rs', + ).readAsStringSync(); + final match = RegExp( + r'const TTS_LICENSE_TEXT: &str = "\\\n([\s\S]*?)\n";', + ).firstMatch(desktopModels); + + expect(match, isNotNull); + final canonical = '${match!.group(1)!.replaceAll(r'\"', '"')}\n'; + expect(pocketModelLicenseText, canonical); + }); } PocketModelDownloader _downloader( Directory support, PocketModelArtifact artifact, { - required http.Client client, + http.Client? client, + http.Client Function()? clientFactory, int capacity = 1024 * 1024 * 1024, void Function(String)? excludeFromBackup, + Duration sendTimeout = const Duration(seconds: 30), + Duration bodyIdleTimeout = const Duration(seconds: 30), }) => PocketModelDownloader( - clientFactory: () => client, + clientFactory: clientFactory ?? () => client!, applicationSupportDirectory: () async => support, availableCapacity: (_) async => capacity, excludeFromBackup: (path) async => excludeFromBackup?.call(path), artifacts: [artifact], installationId: 'test', + sendTimeout: sendTimeout, + bodyIdleTimeout: bodyIdleTimeout, ); class _PendingClient extends http.BaseClient { @@ -167,3 +235,33 @@ class _PendingClient extends http.BaseClient { unawaited(_response.close()); } } + +class _NeverSendingClient extends http.BaseClient { + bool closed = false; + + @override + Future send(http.BaseRequest request) => + Completer().future; + + @override + void close() { + closed = true; + } +} + +class _StallingBodyClient extends http.BaseClient { + final _response = StreamController>(); + bool closed = false; + + @override + Future send(http.BaseRequest request) async { + scheduleMicrotask(() => _response.add('he'.codeUnits)); + return http.StreamedResponse(_response.stream, 200, contentLength: 5); + } + + @override + void close() { + closed = true; + unawaited(_response.close()); + } +} diff --git a/mobile/test/shared/voice/pocket_voice_controller_test.dart b/mobile/test/shared/voice/pocket_voice_controller_test.dart new file mode 100644 index 0000000000..f503d54504 --- /dev/null +++ b/mobile/test/shared/voice/pocket_voice_controller_test.dart @@ -0,0 +1,281 @@ +import 'dart:async'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:buzz/shared/relay/relay.dart'; +import 'package:buzz/shared/voice/pocket_model_provider.dart'; +import 'package:buzz/shared/voice/pocket_voice_controller.dart'; +import 'package:buzz/shared/voice/pocket_voice_worker.dart'; +import 'package:buzz/shared/voice/voice_audio_output.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + test('queues assistant messages and plays every chunk in order', () async { + final worker = _FakeWorker(); + final output = _FakeAudioOutput(); + final container = _container(worker, output); + addTearDown(container.dispose); + final notifier = container.read(pocketVoiceProvider.notifier); + + await notifier.enable('conversation'); + notifier.speak('conversation', 'First response.'); + notifier.speak('conversation', 'Second response.'); + + expect(worker.syntheses, [(1, 'First response.')]); + + worker.emitAudio(1, [1, 2], isLast: false); + await _flush(); + expect(output.played, hasLength(1)); + expect(output.played[0].$1, [1, 2]); + expect(output.played[0].$2, 24000); + + worker.emitAudio(1, [3, 4], isLast: true); + await _flush(); + expect(output.played, hasLength(1)); + output.complete(); + await _flush(); + expect(output.played, hasLength(2)); + expect(output.played[1].$1, [3, 4]); + expect(worker.syntheses, [(1, 'First response.')]); + + output.complete(); + await _flush(); + expect(worker.syntheses, [(1, 'First response.'), (2, 'Second response.')]); + + worker.emitAudio(2, [5, 6], isLast: true); + await _flush(); + output.complete(); + await _flush(); + + expect(output.played, hasLength(3)); + expect(output.played[0].$1, [1, 2]); + expect(output.played[1].$1, [3, 4]); + expect(output.played[2].$1, [5, 6]); + expect( + container.read(pocketVoiceProvider).phase, + PocketVoicePhase.listening, + ); + }); + + test( + 'disable wins an overlapping engine startup and retains warm worker', + () async { + final worker = _FakeWorker(startPaused: true); + final output = _FakeAudioOutput(); + final container = _container(worker, output); + addTearDown(container.dispose); + final notifier = container.read(pocketVoiceProvider.notifier); + + final enabling = notifier.enable('conversation'); + await _flush(); + final disabling = notifier.disable(); + worker.finishStart(); + await Future.wait([enabling, disabling]); + + expect(container.read(pocketVoiceProvider).phase, PocketVoicePhase.off); + expect(worker.startCount, 1); + expect(worker.disposeCount, 0); + + await notifier.enable('next-conversation'); + expect(worker.startCount, 1); + expect( + container.read(pocketVoiceProvider).phase, + PocketVoicePhase.listening, + ); + }, + ); + + test( + 'preserves text submitted while the resident engine is loading', + () async { + final worker = _FakeWorker(startPaused: true); + final output = _FakeAudioOutput(); + final container = _container(worker, output); + addTearDown(container.dispose); + final notifier = container.read(pocketVoiceProvider.notifier); + final longResponse = 'A' * 2100; + + final enabling = notifier.enable('conversation'); + await _flush(); + notifier.speak('conversation', longResponse); + worker.finishStart(); + await enabling; + + expect(worker.syntheses, [(1, longResponse)]); + }, + ); + + test( + 'queues new responses during interrupt and rejects them during disable', + () async { + final worker = _FakeWorker(); + final output = _FakeAudioOutput(); + final container = _container(worker, output); + addTearDown(container.dispose); + final notifier = container.read(pocketVoiceProvider.notifier); + + await notifier.enable('conversation'); + notifier.speak('conversation', 'Before interrupt.'); + worker.pauseCancellation(); + final interrupting = notifier.interrupt(); + await _flush(); + notifier.speak('conversation', 'After steering.'); + expect(worker.syntheses, [(1, 'Before interrupt.')]); + + worker.finishCancellation(); + await interrupting; + expect(worker.syntheses, [ + (1, 'Before interrupt.'), + (2, 'After steering.'), + ]); + + worker.pauseCancellation(); + final disabling = notifier.disable(); + notifier.speak('conversation', 'Must not be spoken.'); + worker.finishCancellation(); + await disabling; + expect(worker.syntheses, hasLength(2)); + expect(container.read(pocketVoiceProvider).phase, PocketVoicePhase.off); + }, + ); + + test('surfaces asynchronous native playback errors', () async { + final worker = _FakeWorker(); + final output = _FakeAudioOutput(); + final container = _container(worker, output); + addTearDown(container.dispose); + final notifier = container.read(pocketVoiceProvider.notifier); + + await notifier.enable('conversation'); + notifier.speak('conversation', 'Response.'); + worker.emitAudio(1, [1, 2], isLast: true); + await _flush(); + output.fail(); + await _flush(); + + final state = container.read(pocketVoiceProvider); + expect(state.phase, PocketVoicePhase.error); + expect(state.error, 'Pocket voice playback failed.'); + expect(worker.cancelCount, greaterThan(0)); + + notifier.speak('conversation', 'Must wait for explicit recovery.'); + await _flush(); + expect(worker.syntheses, [(1, 'Response.')]); + expect(container.read(pocketVoiceProvider), same(state)); + }); +} + +ProviderContainer _container(_FakeWorker worker, _FakeAudioOutput output) => + ProviderContainer( + overrides: [ + relayConfigProvider.overrideWith(_TestRelayConfigNotifier.new), + pocketModelProvider.overrideWith(_ReadyPocketModelNotifier.new), + pocketVoiceWorkerFactoryProvider.overrideWithValue(() => worker), + voiceAudioOutputProvider.overrideWithValue(output), + ], + ); + +Future _flush() => Future.delayed(Duration.zero); + +class _ReadyPocketModelNotifier extends PocketModelNotifier { + @override + PocketModelState build() => const PocketModelState( + phase: PocketModelPhase.ready, + path: '/tmp/pocket-model', + ); +} + +class _TestRelayConfigNotifier extends RelayConfigNotifier { + @override + RelayConfig build() => const RelayConfig(baseUrl: 'http://localhost:3000'); +} + +class _FakeWorker extends PocketVoiceWorker { + final StreamController _controller = + StreamController.broadcast(); + final Completer? _startGate; + final List<(int, String)> syntheses = []; + Completer? _cancelGate; + bool _ready = false; + int startCount = 0; + int disposeCount = 0; + int cancelCount = 0; + + _FakeWorker({bool startPaused = false}) + : _startGate = startPaused ? Completer() : null; + + @override + Stream get responses => _controller.stream; + + @override + bool get isReady => _ready; + + @override + Future start(String modelPath) async { + startCount += 1; + await _startGate?.future; + _ready = true; + } + + void finishStart() => _startGate?.complete(); + + @override + void synthesize(int generation, String text) { + syntheses.add((generation, text)); + } + + void emitAudio(int generation, List bytes, {required bool isLast}) { + _controller.add( + PocketWorkerAudio( + generation: generation, + data: TransferableTypedData.fromList([Uint8List.fromList(bytes)]), + sampleRate: 24000, + synthesisTime: const Duration(milliseconds: 1), + isLast: isLast, + ), + ); + } + + @override + void cancel() { + cancelCount += 1; + } + + @override + Future cancelAndWait() async { + await _cancelGate?.future; + _cancelGate = null; + } + + void pauseCancellation() => _cancelGate = Completer(); + + void finishCancellation() => _cancelGate?.complete(); + + @override + Future dispose() async { + disposeCount += 1; + await _controller.close(); + } +} + +class _FakeAudioOutput implements VoiceAudioOutput { + final StreamController _controller = + StreamController.broadcast(); + final List<(List, int)> played = []; + + @override + Stream get events => _controller.stream; + + @override + Future play(Uint8List pcm, int sampleRate) async { + played.add((pcm.toList(), sampleRate)); + } + + void complete() => _controller.add(VoiceAudioEvent.completed); + + void fail() => _controller.add(VoiceAudioEvent.error); + + @override + Future stop() async {} +} diff --git a/scripts/mobile-voice-native.sh b/scripts/mobile-voice-native.sh index 0298bbad24..02d9e08be4 100755 --- a/scripts/mobile-voice-native.sh +++ b/scripts/mobile-voice-native.sh @@ -110,6 +110,7 @@ build_ios() { fetch_native build_ios_target "aarch64-apple-ios" "iphoneos" "arm64" build_ios_target "aarch64-apple-ios-sim" "iphonesimulator" "arm64" + build_ios_target "x86_64-apple-ios" "iphonesimulator" "x86_64" } build_ios_current() { @@ -119,6 +120,8 @@ build_ios_current() { if [[ -z "$arch" || "$arch" == "undefined_arch" ]]; then if [[ " ${ARCHS:-} " == *" arm64 "* ]]; then arch="arm64" + elif [[ " ${ARCHS:-} " == *" x86_64 "* ]]; then + arch="x86_64" fi fi case "${platform}:${arch}" in @@ -128,6 +131,9 @@ build_ios_current() { iphonesimulator:arm64) build_ios_target "aarch64-apple-ios-sim" "iphonesimulator" "arm64" ;; + iphonesimulator:x86_64) + build_ios_target "x86_64-apple-ios" "iphonesimulator" "x86_64" + ;; *) echo "unsupported iOS platform/architecture: ${platform:-unset}/${arch:-unset}" >&2 exit 1 @@ -152,10 +158,25 @@ build_android_abi() { echo "ANDROID_NDK_HOME must point at an Android NDK" >&2 exit 1 fi - local toolchain="${ndk_root}/toolchains/llvm/prebuilt/darwin-arm64" - if [[ ! -d "$toolchain" ]]; then - toolchain="${ndk_root}/toolchains/llvm/prebuilt/darwin-x86_64" - fi + local host_os + case "$(uname -s)" in + Darwin) host_os="darwin" ;; + Linux) host_os="linux" ;; + MINGW* | MSYS* | CYGWIN*) host_os="windows" ;; + *) + echo "Unsupported Android NDK host OS: $(uname -s)" >&2 + exit 1 + ;; + esac + local host_arch + case "$(uname -m)" in + arm64 | aarch64) host_arch="arm64" ;; + x86_64 | amd64) host_arch="x86_64" ;; + *) + echo "Unsupported Android NDK host architecture: $(uname -m)" >&2 + exit 1 + ;; + esac local api="${BUZZ_ANDROID_MIN_API:-24}" local clang_target if [[ "$abi" == "arm64-v8a" ]]; then @@ -163,7 +184,19 @@ build_android_abi() { else clang_target="x86_64-linux-android${api}" fi + local prebuilt_root="${ndk_root}/toolchains/llvm/prebuilt" + local toolchain="${prebuilt_root}/${host_os}-${host_arch}" local linker="${toolchain}/bin/${clang_target}-clang" + if [[ ! -x "$linker" ]]; then + local candidate + for candidate in "${prebuilt_root}/${host_os}-"*; do + if [[ -x "${candidate}/bin/${clang_target}-clang" ]]; then + toolchain="$candidate" + linker="${candidate}/bin/${clang_target}-clang" + break + fi + done + fi if [[ ! -x "$linker" ]]; then echo "Android linker not found: $linker" >&2 exit 1 From 2d934cb7b02d50b3504faf7cd8e463376f817431 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 06:36:19 -0400 Subject: [PATCH 4/8] fix(voice): satisfy canonical formatting gates Signed-off-by: John Tennant --- crates/buzz-voice-mobile/src/lib.rs | 16 +++++++--------- desktop/src-tauri/src/huddle/tts.rs | 17 ----------------- 2 files changed, 7 insertions(+), 26 deletions(-) diff --git a/crates/buzz-voice-mobile/src/lib.rs b/crates/buzz-voice-mobile/src/lib.rs index 654ca95956..7eee504124 100644 --- a/crates/buzz-voice-mobile/src/lib.rs +++ b/crates/buzz-voice-mobile/src/lib.rs @@ -130,15 +130,13 @@ pub extern "C" fn buzz_voice_engine_synthesize( // not overlap. Cancellation only touches the atomic flag. let engine = unsafe { &*(engine.cast::()) }; let cancelled = Arc::clone(&engine.cancelled); - let samples = match engine - .tts - .synth_chunk_with_callback( - &text, - "en", - &engine.voice, - 1, - Some(move |_: &[f32], _| !cancelled.load(Ordering::Acquire)), - ) { + let samples = match engine.tts.synth_chunk_with_callback( + &text, + "en", + &engine.voice, + 1, + Some(move |_: &[f32], _| !cancelled.load(Ordering::Acquire)), + ) { Ok(value) => value, Err(error) => return pcm_error(error), }; diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 5f649be786..bfec9cb05d 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -93,23 +93,6 @@ const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize; #[cfg(test)] const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; -/// Approximate character budget for one synthesis chunk. -/// -/// Upstream pocket-tts groups sentences into chunks of up to -/// `MAX_TOKEN_PER_CHUNK = 50` tokenizer tokens (`default_parameters.py`) — -/// typically multi-sentence chunks — because every `generate()` call is an -/// independent generation with a cold FlowLM start, and each chunk boundary -/// is an exposed prosody seam (kyutai-labs/pocket-tts #151; the Kyutai team -/// names chunk stitching as the reliability lever). Our previous -/// sentence-per-call path created ~2–4× more seams than upstream. -/// -/// We don't ship the SentencePiece tokenizer, so 50 tokens is approximated -/// with a character budget. The bundled 4k-entry vocab averages ~4 chars per -/// token, but usage-weighted English text leans on short common tokens, so -/// the effective ratio is ~2–4 chars/token and 200 chars ≈ 60–100 tokens — -/// modestly above upstream's 50, deliberately: erring large means fewer -/// seams, and even ~100 tokens is far below the model's 500-LM-step (~40 s) -/// ceiling. Do not shrink this budget to chase an exact 50-token match. // ── Public pipeline handle ──────────────────────────────────────────────────── /// Handle to the running TTS pipeline. From 6554523f0dbabac562bae5ac60c86e69c7e91412 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 06:50:16 -0400 Subject: [PATCH 5/8] fix(mobile): scope Pocket settings golden to macOS Signed-off-by: John Tennant --- .../settings/pocket_voice_settings_test.dart | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/mobile/test/features/settings/pocket_voice_settings_test.dart b/mobile/test/features/settings/pocket_voice_settings_test.dart index 34721cff90..0d81c11cb4 100644 --- a/mobile/test/features/settings/pocket_voice_settings_test.dart +++ b/mobile/test/features/settings/pocket_voice_settings_test.dart @@ -1,3 +1,5 @@ +import 'dart:io'; + import 'package:buzz/features/settings/settings_page.dart'; import 'package:buzz/shared/theme/theme.dart'; import 'package:buzz/shared/voice/pocket_model_provider.dart'; @@ -53,10 +55,16 @@ void main() { expect(find.textContaining('Downloads 473 MB'), findsOneWidget); expect(find.text('473 MB'), findsOneWidget); - await expectLater( - find.byKey(screenshotKey), - matchesGoldenFile('goldens/pocket_voice_settings.png'), - ); + // Flutter's text rasterization differs between macOS and the Linux CI + // runner even with the same bundled fonts. Keep the pixel baseline on the + // platform that produces the reviewer screenshot; the assertions above + // remain the cross-platform contract for the download state. + if (Platform.isMacOS) { + await expectLater( + find.byKey(screenshotKey), + matchesGoldenFile('goldens/pocket_voice_settings.png'), + ); + } }); } From cc2879495b954cca336511df8e536aad729fadea Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 07:02:49 -0400 Subject: [PATCH 6/8] fix(mobile): install Android Rust targets Signed-off-by: John Tennant --- scripts/mobile-voice-native.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/mobile-voice-native.sh b/scripts/mobile-voice-native.sh index 02d9e08be4..2e760fae5e 100755 --- a/scripts/mobile-voice-native.sh +++ b/scripts/mobile-voice-native.sh @@ -206,6 +206,9 @@ build_android_abi() { target_env="$(printf '%s' "$target" | tr '[:lower:]-' '[:upper:]_')" local linker_var="CARGO_TARGET_${target_env}_LINKER" local rustflags_var="CARGO_TARGET_${target_env}_RUSTFLAGS" + if ! rustup target list --installed | grep -Fxq "$target"; then + rustup target add "$target" + fi env \ SHERPA_ONNX_LIB_DIR="$source_libs" \ "$linker_var=$linker" \ From f0d9f36a7c51051795e4b6853628f7eb4ed6c600 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 08:46:36 -0400 Subject: [PATCH 7/8] feat(mobile): adopt April Pocket FP32 runtime Signed-off-by: John Tennant --- .../include/buzz_voice_mobile.h | 8 +- crates/buzz-voice-mobile/src/lib.rs | 98 +++++++++++++++--- .../xyz/block/buzz/mobile/VoiceAudioOutput.kt | 17 +++ .../pocket_voice_integration_test.dart | 7 ++ mobile/ios/Runner/VoiceAudioOutput.swift | 1 + mobile/ios/RunnerTests/RunnerTests.swift | 4 +- .../channels/channel_detail_page.dart | 4 +- .../settings/settings_page/voice_section.dart | 3 +- .../shared/voice/pocket_model_downloader.dart | 24 ++++- .../shared/voice/pocket_model_manifest.dart | 78 ++++++++------ .../shared/voice/pocket_voice_controller.dart | 43 ++++++-- mobile/lib/shared/voice/pocket_voice_ffi.dart | 18 ++-- .../lib/shared/voice/pocket_voice_worker.dart | 18 +++- .../lib/shared/voice/voice_audio_output.dart | 10 +- .../channels/channel_detail_page_test.dart | 2 + .../goldens/pocket_voice_settings.png | Bin 52489 -> 52383 bytes .../settings/pocket_voice_settings_test.dart | 4 +- .../voice/pocket_model_downloader_test.dart | 74 +++++++++---- .../voice/pocket_voice_controller_test.dart | 43 ++++++++ 19 files changed, 359 insertions(+), 97 deletions(-) diff --git a/crates/buzz-voice-mobile/include/buzz_voice_mobile.h b/crates/buzz-voice-mobile/include/buzz_voice_mobile.h index cfb199999e..a82fac4d21 100644 --- a/crates/buzz-voice-mobile/include/buzz_voice_mobile.h +++ b/crates/buzz-voice-mobile/include/buzz_voice_mobile.h @@ -16,8 +16,12 @@ typedef struct BuzzVoicePcm { char *error; } BuzzVoicePcm; -BuzzVoiceEngineResult buzz_voice_engine_create(const char *model_dir); -char *buzz_voice_prepare_chunks_json(const char *text); +#define BUZZ_VOICE_PRECISION_FP32 0 +#define BUZZ_VOICE_PRECISION_INT8 1 + +BuzzVoiceEngineResult buzz_voice_engine_create(const char *model_dir, + uint8_t precision); +char *buzz_voice_prepare_chunks_json(void *engine, const char *text); BuzzVoicePcm buzz_voice_engine_synthesize(void *engine, const char *text); void buzz_voice_engine_cancel(void *engine); void buzz_voice_engine_reset_cancel(void *engine); diff --git a/crates/buzz-voice-mobile/src/lib.rs b/crates/buzz-voice-mobile/src/lib.rs index 7eee504124..ca6339ad28 100644 --- a/crates/buzz-voice-mobile/src/lib.rs +++ b/crates/buzz-voice-mobile/src/lib.rs @@ -8,8 +8,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use buzz_voice::pocket::{ - load_text_to_speech, load_voice_style, PocketTts, VoiceStyle, DEFAULT_VOICE, SAMPLE_RATE, - VOICE_FILE_EXT, + load_text_to_speech_with_options, load_voice_style, PocketLoadOptions, PocketModelSelection, + PocketPrecision, PocketTts, VoiceStyle, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, }; use buzz_voice::preparation::{prepare_tts_chunks, shape_tts_chunk}; @@ -80,15 +80,27 @@ fn input_string(value: *const c_char, label: &str) -> Result { /// Create and retain one Pocket engine and its reference voice. #[no_mangle] -pub extern "C" fn buzz_voice_engine_create(model_dir: *const c_char) -> BuzzVoiceEngineResult { +pub extern "C" fn buzz_voice_engine_create( + model_dir: *const c_char, + precision: u8, +) -> BuzzVoiceEngineResult { let model_dir = match input_string(model_dir, "model_dir") { Ok(value) => value, Err(error) => return engine_error(error), }; + let precision = match precision { + value if value == PocketPrecision::Fp32 as u8 => PocketPrecision::Fp32, + value if value == PocketPrecision::Int8 as u8 => PocketPrecision::Int8, + value => return engine_error(format!("unsupported Pocket precision: {value}")), + }; let voice_path = std::path::Path::new(&model_dir) .join(DEFAULT_VOICE) .with_extension(VOICE_FILE_EXT); - let tts = match load_text_to_speech(&model_dir) { + let options = PocketLoadOptions { + model: PocketModelSelection::English2026_04(precision), + ..PocketLoadOptions::default() + }; + let tts = match load_text_to_speech_with_options(&model_dir, options) { Ok(value) => value, Err(error) => return engine_error(error), }; @@ -166,12 +178,28 @@ pub extern "C" fn buzz_voice_engine_synthesize( /// The returned pointer is released with [`buzz_voice_string_free`]. A null /// pointer indicates invalid input or an unexpected serialization failure. #[no_mangle] -pub extern "C" fn buzz_voice_prepare_chunks_json(text: *const c_char) -> *mut c_char { +pub extern "C" fn buzz_voice_prepare_chunks_json( + engine: *mut c_void, + text: *const c_char, +) -> *mut c_char { + if engine.is_null() { + return ptr::null_mut(); + } let text = match input_string(text, "text") { Ok(value) => value, Err(_) => return ptr::null_mut(), }; - let json = match serde_json::to_string(&prepare_tts_chunks(&text)) { + // SAFETY: See `buzz_voice_engine_synthesize`. + let engine = unsafe { &*(engine.cast::()) }; + let mut chunks = Vec::new(); + for prepared in prepare_tts_chunks(&text) { + let model_chunks = match engine.tts.split_text_into_chunks(&prepared) { + Ok(value) => value, + Err(_) => return ptr::null_mut(), + }; + chunks.extend(model_chunks); + } + let json = match serde_json::to_string(&chunks) { Ok(value) => value, Err(_) => return ptr::null_mut(), }; @@ -244,7 +272,7 @@ mod tests { #[test] fn null_inputs_return_owned_errors() { - let engine = buzz_voice_engine_create(ptr::null()); + let engine = buzz_voice_engine_create(ptr::null(), PocketPrecision::Fp32 as u8); assert!(engine.engine.is_null()); assert!(!engine.error.is_null()); buzz_voice_string_free(engine.error); @@ -258,15 +286,57 @@ mod tests { } #[test] - fn chunk_preparation_returns_owned_json() { + fn chunk_preparation_requires_an_engine() { let input = CString::new("First. Second. Third.").expect("test input"); - let json = buzz_voice_prepare_chunks_json(input.as_ptr()); + let json = buzz_voice_prepare_chunks_json(ptr::null_mut(), input.as_ptr()); + assert!(json.is_null()); + } + + #[test] + fn invalid_precision_returns_an_owned_error() { + let model = CString::new("/tmp/pocket").expect("test path"); + let engine = buzz_voice_engine_create(model.as_ptr(), u8::MAX); + assert!(engine.engine.is_null()); + assert!(!engine.error.is_null()); + buzz_voice_string_free(engine.error); + } + + #[test] + #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] + fn april_fp32_abi_prepares_and_synthesizes_valid_pcm() { + let model = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR") + .expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April FP32 bundle"); + let model = CString::new(model).expect("test model path"); + let result = buzz_voice_engine_create(model.as_ptr(), PocketPrecision::Fp32 as u8); + if !result.error.is_null() { + // SAFETY: The ABI returned a live NUL-terminated owned error. + let error = unsafe { CStr::from_ptr(result.error) } + .to_string_lossy() + .into_owned(); + buzz_voice_string_free(result.error); + panic!("engine creation failed: {error}"); + } + assert!(!result.engine.is_null()); + + let text = CString::new( + "Pocket voice is running on mobile. This second sentence verifies shared chunking.", + ) + .expect("test text"); + let json = buzz_voice_prepare_chunks_json(result.engine, text.as_ptr()); assert!(!json.is_null()); - // SAFETY: The function returned a live NUL-terminated owned string. - let value = unsafe { CStr::from_ptr(json) } - .to_str() - .expect("UTF-8 JSON"); - assert_eq!(value, r#"["First.","Second. Third."]"#); + // SAFETY: The ABI returned a live NUL-terminated owned JSON string. + let chunks: Vec = + serde_json::from_slice(unsafe { CStr::from_ptr(json) }.to_bytes()).expect("chunk JSON"); buzz_voice_string_free(json); + assert_eq!(chunks.len(), 2); + + let prompt = CString::new(chunks[0].as_str()).expect("prepared prompt"); + let pcm = buzz_voice_engine_synthesize(result.engine, prompt.as_ptr()); + assert!(pcm.error.is_null()); + assert!(!pcm.samples.is_null()); + assert!(pcm.len > 24_000 / 4); + assert_eq!(pcm.sample_rate, 24_000); + buzz_voice_pcm_free(pcm); + buzz_voice_engine_destroy(result.engine); } } diff --git a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt index d22f2086d3..861cdfec0c 100644 --- a/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt +++ b/mobile/android/app/src/main/kotlin/xyz/block/buzz/mobile/VoiceAudioOutput.kt @@ -161,6 +161,21 @@ internal class VoiceAudioOutput( thread(name = "buzz-pocket-playback") { var offset = 0 var complete = false + var started = false + fun reportStartedIfPlaybackAdvanced() { + if (started || generation.get() != currentGeneration) return + val playedFrames = + runCatching { + nextTrack.playbackHeadPosition.toLong() and 0xFFFF_FFFFL + }.getOrNull() ?: return + if (playedFrames == 0L) return + started = true + mainHandler.post { + if (generation.get() == currentGeneration && track === nextTrack) { + channel.invokeMethod("started", null) + } + } + } try { while (offset < pcm.size && generation.get() == currentGeneration) { val written = nextTrack.write( @@ -171,6 +186,7 @@ internal class VoiceAudioOutput( ) if (written <= 0) break offset += written + reportStartedIfPlaybackAdvanced() } complete = offset == pcm.size if (complete) { @@ -188,6 +204,7 @@ internal class VoiceAudioOutput( runCatching { nextTrack.playbackHeadPosition.toLong() and 0xFFFF_FFFFL }.getOrNull() ?: break + reportStartedIfPlaybackAdvanced() if (playedFrames >= targetFrames) { drained = true break diff --git a/mobile/integration_test/pocket_voice_integration_test.dart b/mobile/integration_test/pocket_voice_integration_test.dart index a3ada5d33d..16e6f55307 100644 --- a/mobile/integration_test/pocket_voice_integration_test.dart +++ b/mobile/integration_test/pocket_voice_integration_test.dart @@ -44,6 +44,7 @@ void main() { addTearDown(worker.dispose); final firstClock = Stopwatch()..start(); + final firstPlaybackClock = Stopwatch()..start(); worker.synthesize(1, 'Pocket voice is running on this mobile device.'); final first = await worker.responses .where((response) => response is PocketWorkerAudio) @@ -59,11 +60,16 @@ void main() { Duration.microsecondsPerSecond / audioSeconds; final output = PlatformVoiceAudioOutput(); + final playbackStarted = output.events.firstWhere( + (event) => event == VoiceAudioEvent.started, + ); final playbackCompleted = output.events.firstWhere( (event) => event == VoiceAudioEvent.completed, ); final playbackClock = Stopwatch()..start(); await output.play(bytes, first.sampleRate); + await playbackStarted.timeout(const Duration(seconds: 30)); + firstPlaybackClock.stop(); await playbackCompleted.timeout(const Duration(seconds: 30)); playbackClock.stop(); expect( @@ -105,6 +111,7 @@ void main() { debugPrint( 'BUZZ_POCKET_METRICS ' 'first_pcm_ms=${firstClock.elapsedMilliseconds} ' + 'ttfap_ms=${firstPlaybackClock.elapsedMilliseconds} ' 'synthesis_ms=${first.synthesisTime.inMilliseconds} ' 'audio_s=${audioSeconds.toStringAsFixed(3)} ' 'rtf=${rtf.toStringAsFixed(3)} ' diff --git a/mobile/ios/Runner/VoiceAudioOutput.swift b/mobile/ios/Runner/VoiceAudioOutput.swift index 033a53fa73..3340c0d4dd 100644 --- a/mobile/ios/Runner/VoiceAudioOutput.swift +++ b/mobile/ios/Runner/VoiceAudioOutput.swift @@ -94,6 +94,7 @@ final class VoiceAudioOutput: NSObject, AVAudioPlayerDelegate { ) } player = nextPlayer + channel.invokeMethod("started", arguments: nil) } catch { try? session.setActive(false, options: [.notifyOthersOnDeactivation]) throw error diff --git a/mobile/ios/RunnerTests/RunnerTests.swift b/mobile/ios/RunnerTests/RunnerTests.swift index 8a5bf834a5..d8fd610bf6 100644 --- a/mobile/ios/RunnerTests/RunnerTests.swift +++ b/mobile/ios/RunnerTests/RunnerTests.swift @@ -25,7 +25,9 @@ class RunnerTests: XCTestCase { } let loadStart = ContinuousClock.now - let engineResult = modelPath.withCString(buzz_voice_engine_create) + let engineResult = modelPath.withCString { + buzz_voice_engine_create($0, UInt8(BUZZ_VOICE_PRECISION_FP32)) + } let loadTime = loadStart.duration(to: .now) if let error = engineResult.error { defer { buzz_voice_string_free(error) } diff --git a/mobile/lib/features/channels/channel_detail_page.dart b/mobile/lib/features/channels/channel_detail_page.dart index 9987b8de01..36a8631df1 100644 --- a/mobile/lib/features/channels/channel_detail_page.dart +++ b/mobile/lib/features/channels/channel_detail_page.dart @@ -137,6 +137,7 @@ class ChannelDetailPage extends HookConsumerWidget { final voiceEventKey = messagesState.value?.map((event) => event.id).join(',') ?? ''; useEffect(() { + if (channel.isForum) return null; final events = messagesState.value; if (events == null) return null; final spoken = voiceConversation.update( @@ -276,7 +277,8 @@ class ChannelDetailPage extends HookConsumerWidget { ], ), actions: [ - PocketVoiceButton(conversationKey: 'channel:${channel.id}'), + if (!resolvedChannel.isForum) + PocketVoiceButton(conversationKey: 'channel:${channel.id}'), _MembersButton( channelId: resolvedChannel.id, channel: resolvedChannel, diff --git a/mobile/lib/features/settings/settings_page/voice_section.dart b/mobile/lib/features/settings/settings_page/voice_section.dart index e3a2cb14ef..6ac53448db 100644 --- a/mobile/lib/features/settings/settings_page/voice_section.dart +++ b/mobile/lib/features/settings/settings_page/voice_section.dart @@ -35,7 +35,8 @@ class _VoiceSection extends ConsumerWidget { title: 'Pocket voice', subtitle: model.message ?? - 'Private, on-device speech. Downloads 473 MB after install.', + 'Private, on-device speech. Downloads ' + '${pocketModelRuntimeBytes ~/ 1000000} MB after install.', subtitleMaxLines: 3, value: status, trailing: downloading diff --git a/mobile/lib/shared/voice/pocket_model_downloader.dart b/mobile/lib/shared/voice/pocket_model_downloader.dart index 72171f849c..38725bf273 100644 --- a/mobile/lib/shared/voice/pocket_model_downloader.dart +++ b/mobile/lib/shared/voice/pocket_model_downloader.dart @@ -12,6 +12,7 @@ import 'package:uuid/uuid.dart'; import 'pocket_model_manifest.dart'; const _manifestName = '.buzz-model-manifest'; +const _legacyPocketModelVersion = '3'; const _storageChannel = MethodChannel('buzz/voice_audio'); const _minimumReserveBytes = 64 * 1024 * 1024; const _defaultSendTimeout = Duration(seconds: 30); @@ -85,7 +86,11 @@ class PocketModelDownloader { Future isReady() async { final directory = await modelDirectory(); - return verify(directory, hashContents: false); + final ready = await verify(directory, hashContents: false); + if (ready) { + await _cleanupLegacyVersion(directory.parent); + } + return ready; } Future verify(Directory directory, {required bool hashContents}) async { @@ -95,6 +100,8 @@ class PocketModelDownloader { final decoded = jsonDecode(await manifest.readAsString()) as Map; if (decoded['version'] != pocketModelVersion || + decoded['revision'] != pocketModelRevision || + decoded['precision'] != pocketModelPrecision || decoded['complete'] != true) { return false; } @@ -154,6 +161,8 @@ class PocketModelDownloader { await File('${staging.path}/$_manifestName').writeAsString( jsonEncode({ 'version': pocketModelVersion, + 'revision': pocketModelRevision, + 'precision': pocketModelPrecision, 'complete': true, 'artifacts': [ for (final artifact in _artifacts) @@ -190,6 +199,7 @@ class PocketModelDownloader { await backup.delete(recursive: true); } await excludeFromBackup(finalDirectory.path); + await _cleanupLegacyVersion(parent); return finalDirectory; } on PocketDownloadCancelled { rethrow; @@ -339,6 +349,18 @@ class PocketModelDownloader { } } + Future _cleanupLegacyVersion(Directory parent) async { + final legacy = Directory('${parent.path}/v$_legacyPocketModelVersion'); + try { + if (await legacy.exists()) { + await legacy.delete(recursive: true); + } + } on FileSystemException { + // The verified v4 model remains usable. A later readiness check retries + // cleanup instead of turning a successful upgrade into an error state. + } + } + void _throwIfCancelled() { if (_cancelled) throw const PocketDownloadCancelled(); } diff --git a/mobile/lib/shared/voice/pocket_model_manifest.dart b/mobile/lib/shared/voice/pocket_model_manifest.dart index 971bc81f78..d3676091b5 100644 --- a/mobile/lib/shared/voice/pocket_model_manifest.dart +++ b/mobile/lib/shared/voice/pocket_model_manifest.dart @@ -15,12 +15,17 @@ class PocketModelArtifact { }); } -const pocketModelVersion = '3'; -const pocketModelRuntimeBytes = 473251634; +const pocketModelVersion = '4'; +const pocketModelRevision = '58a6d00cf13d239b6748cb0769f35c580a8f606c'; +const pocketModelPrecision = 'fp32'; +const pocketModelCoreBytes = 439555904; +const pocketModelRuntimeBytes = 440213643; const _pocketModelBase = - 'https://huggingface.co/csukuangfj2/' - 'sherpa-onnx-pocket-tts-2026-01-26/resolve/' - '96d1e53ce3311ca6c2c6a35e2062d36b4cec6fa3'; + 'https://huggingface.co/KevinAHM/pocket-tts-onnx/resolve/' + '$pocketModelRevision/onnx/english_2026-04'; +const _pocketLicenseUrl = + 'https://huggingface.co/KevinAHM/pocket-tts-onnx/resolve/' + '$pocketModelRevision/onnx/LICENSE'; const _pocketVoiceUrl = 'https://huggingface.co/kyutai/tts-voices/resolve/' '323332d33f997de8394f24a193e1a76df720e01a/' @@ -28,39 +33,44 @@ const _pocketVoiceUrl = final pocketModelArtifacts = [ _artifact( - 'decoder.onnx', - 41478706, - 'f267880fde6c58b17b0a8f3647eaf8dcfad321f833f32d583ebc2fb2d1a15f10', + 'bundle.json', + 24381, + 'bab643150f437f37df080a710520ff39ed9ebd9a339f8ebdc739f7eddfc28b3f', ), _artifact( - 'encoder.onnx', - 72713165, - 'e8f2f6d301ffb96e398b138a7dc6d3038622d236044636b73d920bab85890260', + 'bos_before_voice.npy', + 4224, + 'f46edf4f7007b7ba4ea58831f49d003e59e167b4641c44bb3addfe9231a780b1', ), _artifact( - 'lm_flow.onnx', - 39097094, - '79c013a554a54e63319c33c0cc8830cbbedc9b7e448ae7e26f7923ae11f9873e', + 'tokenizer.model', + 59339, + 'd461765ae179566678c93091c5fa6f2984c31bbe990bf1aa62d92c64d91bc3f6', ), _artifact( - 'lm_main.onnx', - 302742148, - '255d1a9263c5abdf36034abfc19c11d21cc5f40f0f87d8361288e972cbd5c578', + 'flow_lm_main.onnx', + 302742149, + '6d18315e2c33ca3e3aa4a4e3dca22f56d007fd823127e24948b37695bf54190f', ), _artifact( - 'text_conditioner.onnx', - 16388343, - '0b84e837d7bfaf2c896627b03e3f080320309f37f4fc7df7698c644f7ba5e6b1', + 'flow_lm_flow.onnx', + 39097095, + '085d239f68897e28fb06e95c743738ad8b8c092ee6dc55f5491313e81ff08062', + ), + _artifact( + 'mimi_decoder.onnx', + 41471926, + '86f038caa02a96a0ff9c25526a0ff43a4906c418197ed72d3e30f720ac7ce802', ), _artifact( - 'vocab.json', - 69478, - '6fb646346cf931016f70c4921aab0900ce7a304b893cb02135c74e294abfea01', + 'mimi_encoder.onnx', + 39768446, + '853e2ca623b8782d94c3745ec6133bfdff7ce33d9b11128bd29ea03f28d76e3d', ), _artifact( - 'token_scores.json', - 123616, - '5be2f278caf9b9800741f0fd82bff677f4943ec764c356f907213434b622d958', + 'text_conditioner.onnx', + 16388344, + '4ecee995fb69f85c7a7493d11f7b5ee15d9950facc7ab3f5c9c49ef1e03847bb', ), PocketModelArtifact( name: 'reference_sample.wav', @@ -68,10 +78,11 @@ final pocketModelArtifacts = [ size: 639084, sha256: 'a35b0468382218e9f37a9a7494d1e4b74deaf18d7ced22265b4e325bb55c183f', ), - _artifact( - 'LICENSE', - 18655, - 'fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6', + PocketModelArtifact( + name: 'LICENSE', + url: Uri.parse(_pocketLicenseUrl), + size: 18655, + sha256: 'fe7b4ce83b8381cc5b216bbb4af73c570688d1b819c73bbaed8ca401f4677cd6', ), ]; @@ -94,9 +105,10 @@ Original model by Kyutai: https://huggingface.co/kyutai/pocket-tts Paper: Charles, Roebel, et al., Pocket TTS (arXiv:2509.06926). Mimi neural codec by Kyutai is bundled as part of the model. -ONNX export by KevinAHM: https://huggingface.co/KevinAHM/pocket-tts-onnx -Sherpa-onnx repackage by csukuangfj / k2-fsa: -https://huggingface.co/csukuangfj2/sherpa-onnx-pocket-tts-2026-01-26 +April 2026 ONNX export by KevinAHM: +https://huggingface.co/KevinAHM/pocket-tts-onnx +Pinned revision: 58a6d00cf13d239b6748cb0769f35c580a8f606c +Bundle: english_2026-04, full-precision graphs. Bundled reference voice (reference_sample.wav): "Mary (f, conversation)" preset from the Kyutai TTS demo voice catalogue diff --git a/mobile/lib/shared/voice/pocket_voice_controller.dart b/mobile/lib/shared/voice/pocket_voice_controller.dart index 96ad61ea43..6fea633974 100644 --- a/mobile/lib/shared/voice/pocket_voice_controller.dart +++ b/mobile/lib/shared/voice/pocket_voice_controller.dart @@ -11,15 +11,19 @@ import 'voice_audio_output.dart'; enum PocketVoicePhase { off, loading, listening, synthesizing, speaking, error } +enum PocketVoiceFailureKind { load, synthesis, playback } + @immutable class PocketVoiceState { final PocketVoicePhase phase; final String? conversationKey; + final PocketVoiceFailureKind? failureKind; final String? error; const PocketVoiceState({ this.phase = PocketVoicePhase.off, this.conversationKey, + this.failureKind, this.error, }); @@ -101,6 +105,7 @@ class PocketVoiceNotifier extends Notifier { state = PocketVoiceState( phase: PocketVoicePhase.error, conversationKey: conversationKey, + failureKind: PocketVoiceFailureKind.load, error: error.toString(), ); } @@ -224,6 +229,7 @@ class PocketVoiceNotifier extends Notifier { state = PocketVoiceState( phase: PocketVoicePhase.error, conversationKey: state.conversationKey, + failureKind: PocketVoiceFailureKind.synthesis, error: error.toString(), ); } @@ -240,7 +246,14 @@ class PocketVoiceNotifier extends Notifier { if (!_playbackActive && _audio.isEmpty) _finishUtterance(); case PocketWorkerFailure(): if (response.generation != _activeGeneration) return; - _failPlayback(response.message); + if (response.kind == PocketWorkerFailureKind.cancelled) { + _finishUtterance(); + return; + } + _failPlayback( + response.message, + failureKind: PocketVoiceFailureKind.synthesis, + ); case PocketWorkerAudio(): if (response.generation != _activeGeneration) return; _audio.add(response); @@ -263,7 +276,10 @@ class PocketVoiceNotifier extends Notifier { ); } catch (error) { if (activeGeneration == _activeGeneration && state.enabled) { - _failPlayback(error.toString()); + _failPlayback( + error.toString(), + failureKind: PocketVoiceFailureKind.playback, + ); } return; } @@ -274,14 +290,16 @@ class PocketVoiceNotifier extends Notifier { await output.stop(); return; } - state = PocketVoiceState( - phase: PocketVoicePhase.speaking, - conversationKey: state.conversationKey, - ); } void _handleAudioEvent(VoiceAudioEvent event) { switch (event) { + case VoiceAudioEvent.started: + if (!_playbackActive || !state.enabled) return; + state = PocketVoiceState( + phase: PocketVoicePhase.speaking, + conversationKey: state.conversationKey, + ); case VoiceAudioEvent.completed: if (!_playbackActive) return; _playbackActive = false; @@ -296,7 +314,12 @@ class PocketVoiceNotifier extends Notifier { ); } case VoiceAudioEvent.error: - if (_playbackActive) _failPlayback('Pocket voice playback failed.'); + if (_playbackActive) { + _failPlayback( + 'Pocket voice playback failed.', + failureKind: PocketVoiceFailureKind.playback, + ); + } case VoiceAudioEvent.interrupted: case VoiceAudioEvent.routeLost: case VoiceAudioEvent.backgrounded: @@ -304,7 +327,10 @@ class PocketVoiceNotifier extends Notifier { } } - void _failPlayback(String message) { + void _failPlayback( + String message, { + required PocketVoiceFailureKind failureKind, + }) { _worker?.cancel(); _utterances.clear(); _activeGeneration = null; @@ -316,6 +342,7 @@ class PocketVoiceNotifier extends Notifier { state = PocketVoiceState( phase: PocketVoicePhase.error, conversationKey: state.conversationKey, + failureKind: failureKind, error: message, ); } diff --git a/mobile/lib/shared/voice/pocket_voice_ffi.dart b/mobile/lib/shared/voice/pocket_voice_ffi.dart index db9491c06e..82d68d4082 100644 --- a/mobile/lib/shared/voice/pocket_voice_ffi.dart +++ b/mobile/lib/shared/voice/pocket_voice_ffi.dart @@ -22,8 +22,8 @@ final class _PcmResult extends ffi.Struct { external ffi.Pointer error; } -typedef _CreateNative = _EngineResult Function(ffi.Pointer); -typedef _CreateDart = _EngineResult Function(ffi.Pointer); +typedef _CreateNative = _EngineResult Function(ffi.Pointer, ffi.Uint8); +typedef _CreateDart = _EngineResult Function(ffi.Pointer, int); typedef _SynthNative = _PcmResult Function(ffi.Pointer, ffi.Pointer); typedef _SynthDart = @@ -32,8 +32,10 @@ typedef _CancelNative = ffi.Void Function(ffi.Pointer); typedef _CancelDart = void Function(ffi.Pointer); typedef _ResetCancelNative = ffi.Void Function(ffi.Pointer); typedef _ResetCancelDart = void Function(ffi.Pointer); -typedef _PrepareNative = ffi.Pointer Function(ffi.Pointer); -typedef _PrepareDart = ffi.Pointer Function(ffi.Pointer); +typedef _PrepareNative = + ffi.Pointer Function(ffi.Pointer, ffi.Pointer); +typedef _PrepareDart = + ffi.Pointer Function(ffi.Pointer, ffi.Pointer); typedef _DestroyNative = ffi.Void Function(ffi.Pointer); typedef _DestroyDart = void Function(ffi.Pointer); typedef _FreePcmNative = ffi.Void Function(_PcmResult); @@ -49,6 +51,8 @@ class PocketVoicePcm { } class PocketVoiceFfi { + static const fp32Precision = 0; + final ffi.DynamicLibrary _library; late final _CreateDart _create; late final _SynthDart _synthesize; @@ -89,7 +93,7 @@ class PocketVoiceFfi { int create(String modelPath) { final path = modelPath.toNativeUtf8(); - final result = _create(path); + final result = _create(path, fp32Precision); calloc.free(path); if (result.error != ffi.nullptr) { throw StateError(_takeError(result.error)); @@ -128,9 +132,9 @@ class PocketVoiceFfi { void resetCancel(int handle) => _resetCancel(ffi.Pointer.fromAddress(handle)); - List prepareChunks(String text) { + List prepareChunks(int handle, String text) { final input = text.toNativeUtf8(); - final result = _prepare(input); + final result = _prepare(ffi.Pointer.fromAddress(handle), input); calloc.free(input); if (result == ffi.nullptr) { throw StateError('Pocket text preparation returned no result.'); diff --git a/mobile/lib/shared/voice/pocket_voice_worker.dart b/mobile/lib/shared/voice/pocket_voice_worker.dart index 2cb60f11fe..ea0ca3eca5 100644 --- a/mobile/lib/shared/voice/pocket_voice_worker.dart +++ b/mobile/lib/shared/voice/pocket_voice_worker.dart @@ -35,11 +35,14 @@ class PocketWorkerDone extends PocketWorkerResponse { const PocketWorkerDone(this.generation); } +enum PocketWorkerFailureKind { load, synthesis, cancelled } + class PocketWorkerFailure extends PocketWorkerResponse { final int? generation; + final PocketWorkerFailureKind kind; final String message; - const PocketWorkerFailure(this.message, {this.generation}); + const PocketWorkerFailure(this.kind, this.message, {this.generation}); } class PocketWorkerStopped extends PocketWorkerResponse { @@ -177,7 +180,10 @@ void _workerMain((SendPort, String) startup) { try { handle = ffi.create(startup.$2); } catch (error) { - output.send((commands.sendPort, PocketWorkerFailure(error.toString()))); + output.send(( + commands.sendPort, + PocketWorkerFailure(PocketWorkerFailureKind.load, error.toString()), + )); commands.close(); return; } @@ -186,7 +192,7 @@ void _workerMain((SendPort, String) startup) { switch (command) { case _Synthesize(): try { - final chunks = ffi.prepareChunks(command.text); + final chunks = ffi.prepareChunks(handle, command.text); if (chunks.isEmpty) { output.send(PocketWorkerDone(command.generation)); return; @@ -207,9 +213,13 @@ void _workerMain((SendPort, String) startup) { ); } } catch (error) { + final message = error.toString(); output.send( PocketWorkerFailure( - error.toString(), + message.contains('synthesis cancelled') + ? PocketWorkerFailureKind.cancelled + : PocketWorkerFailureKind.synthesis, + message, generation: command.generation, ), ); diff --git a/mobile/lib/shared/voice/voice_audio_output.dart b/mobile/lib/shared/voice/voice_audio_output.dart index 195cb73ec8..2b65d680ba 100644 --- a/mobile/lib/shared/voice/voice_audio_output.dart +++ b/mobile/lib/shared/voice/voice_audio_output.dart @@ -2,7 +2,14 @@ import 'dart:async'; import 'package:flutter/services.dart'; -enum VoiceAudioEvent { completed, interrupted, routeLost, backgrounded, error } +enum VoiceAudioEvent { + started, + completed, + interrupted, + routeLost, + backgrounded, + error, +} abstract interface class VoiceAudioOutput { Stream get events; @@ -18,6 +25,7 @@ class PlatformVoiceAudioOutput implements VoiceAudioOutput { PlatformVoiceAudioOutput() { _channel.setMethodCallHandler((call) async { final event = switch (call.method) { + 'started' => VoiceAudioEvent.started, 'completed' => VoiceAudioEvent.completed, 'interrupted' => VoiceAudioEvent.interrupted, 'routeLost' => VoiceAudioEvent.routeLost, diff --git a/mobile/test/features/channels/channel_detail_page_test.dart b/mobile/test/features/channels/channel_detail_page_test.dart index af39eb103a..82f226e52a 100644 --- a/mobile/test/features/channels/channel_detail_page_test.dart +++ b/mobile/test/features/channels/channel_detail_page_test.dart @@ -482,6 +482,8 @@ void main() { expect(find.text('Forum threads are not on mobile yet'), findsNothing); // The compose bar for stream messages should not appear. expect(find.text('Message…'), findsNothing); + // Forum posts are not part of the channel-level voice conversation. + expect(find.byTooltip('Start Pocket voice'), findsNothing); }); testWidgets('renders video attachments from imeta tags in the timeline', ( diff --git a/mobile/test/features/settings/goldens/pocket_voice_settings.png b/mobile/test/features/settings/goldens/pocket_voice_settings.png index c8ddeed181233817e0cbbb8ea0b34ee018ca0beb..54df8d67dadfd03d261ac38787944462c2acea34 100644 GIT binary patch delta 34286 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 delta 34540 zcmce;WmJ{X+wMyUs0fHirzj#IAl)D$-3?OG4bsg+BP~+WT~g9r($b6W?(T*&*Zbe^ z-us+!&WAJ37{^Z_thJtJK6Bo2UB8>uc;ui2@L$s9No`CPa z2tQ(gz;6o{d~cz8>_cIK`~UXU|DRsG18LU25Y*OQ%Crimcnp1Vji^JMUTs_} zcpUM4WO99V6mvm4h4*Te_0-;heDliI4&C{9%jp-38U!Ao;&zGAmDg?xlxY<48&M&bImldbPwK0PPp z?1x0jOJdIj{+!G?%-_T$CJruWKTro^rn*t=nFYnEQj0_owe%6L-2XUv;&V7b>8g35 zJB*{4-AorFG4FcA^7pTPtp`sOCh6`$flQ2qztmeduOj!$1hewmc$$(s%OyT?rxHW> zCA{$7ae@sGw?hBk@!#n2mD&U;EJ{fBLdz5VXiqSMKIbdJ3XE0P`%=8zsExx`F7|mk z@<+ViGf^OBdaxZlY88CLaf6~99Nf7Y?CuHhf~nPCt2Bunf!Q*dE4)cKg3=> zMbYA&b>8xKvETeU7@3K$b~1;Dz;4agzTZfFwRjh|wLT`EIse04{?@nyi?_5T0?PM% zoxL|UHpY8(T0dE>n2JTg`Q|5|o9qkRWUu&m8jl<2lT}K{lz>^F`TK_Ut=E+uHB&#t zihzr&d|P3IrCRJ46{YcUhCQ|~o+a`~AI-c%e{eo3iQg`MHQN6D`@Z;Xj-Oj2ooNMh zsA!J_<#19_u|CeprG$rHJFc(cA4BV9xR10kvqhb-uFtn*zKOkgaNdxF!4AZ_ET>$* zlXL85R22Gvi?p$^F<9^(aX%ic3`?PAUx81yqv=X*iHYhSc02?wbZ&S!~LDj3`Q`+MnR=d;ikeduP54*Mk@ z-bk(!2LdJK^5td}+w6B)-S$Jz3MaF!peV$KyMZ_*6%|CeOeF<*d2w-5!qIaHf!j+B zOj3T)MD|#zL~f$^aLj@U3!{T3MOZDnC0F-#sw46uhbia!dXB}d1Gzx)!WEAvw-3eb z)OWIp10{L+j`2OwwDT#<7U&wIo`#-o!}mfbqq^i8S%CkQf422qoVBX;^1_3=Z_+Fh zczk{#<;XfE?y5w1H@hKcvkfjGKYucZoq#VAs~4`2aB%dS?zc$`i|7$9b?;;(djDM= zpHyeh&%*c3{SwD*-4;Sg)A6J65c(Qt8bA0WwBFAWDG}1qfg-TmZfuT~C}^r-Hv5YR zEu|~?hy*-F6bN)pY7xc7m^l*%2Pl;mDz#S>Z*fltl=5t<>-6N7s~4jf=MK7Rx@1#~e8 z0?{5d(gft;d~p|p@De}HX2+46jZGrwDx$L~83RFH{s?SwF+uc}#weD#hS^%|)Mp

=*D4Ng@4h=<1Ybf;jK1~==SIO7> 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

uC0o|oyNTDPO1HS+X)uXEXd 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 diff --git a/mobile/test/features/settings/pocket_voice_settings_test.dart b/mobile/test/features/settings/pocket_voice_settings_test.dart index 0d81c11cb4..a149ff5c73 100644 --- a/mobile/test/features/settings/pocket_voice_settings_test.dart +++ b/mobile/test/features/settings/pocket_voice_settings_test.dart @@ -52,8 +52,8 @@ void main() { expect(find.text('Pocket voice'), findsOneWidget); expect(find.text('Not downloaded'), findsOneWidget); - expect(find.textContaining('Downloads 473 MB'), findsOneWidget); - expect(find.text('473 MB'), findsOneWidget); + expect(find.textContaining('Downloads 440 MB'), findsOneWidget); + expect(find.text('440 MB'), findsOneWidget); // Flutter's text rasterization differs between macOS and the Linux CI // runner even with the same bundled fonts. Keep the pixel baseline on the diff --git a/mobile/test/shared/voice/pocket_model_downloader_test.dart b/mobile/test/shared/voice/pocket_model_downloader_test.dart index c2dc5407d7..a0a1e0479a 100644 --- a/mobile/test/shared/voice/pocket_model_downloader_test.dart +++ b/mobile/test/shared/voice/pocket_model_downloader_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'package:buzz/shared/voice/pocket_model_downloader.dart'; @@ -38,6 +39,9 @@ void main() { client: MockClient((_) async => http.Response('hello', 200)), excludeFromBackup: excluded.add, ); + final legacy = Directory('${temporary.path}/buzz/models/pocket-tts/v3'); + await legacy.create(recursive: true); + await File('${legacy.path}/sentinel').writeAsString('January'); final directory = await downloader.install( (downloaded, total, file) => progress.add((downloaded, total, file)), @@ -45,7 +49,18 @@ void main() { expect(await downloader.verify(directory, hashContents: true), isTrue); expect(await File('${directory.path}/tiny.bin').readAsString(), 'hello'); + final manifest = + jsonDecode( + await File( + '${directory.path}/.buzz-model-manifest', + ).readAsString(), + ) + as Map; + expect(manifest['version'], pocketModelVersion); + expect(manifest['revision'], pocketModelRevision); + expect(manifest['precision'], pocketModelPrecision); expect(progress.last, (5, 5, 'tiny.bin')); + expect(await legacy.exists(), isFalse); expect(excluded, hasLength(2)); expect( excluded.first, @@ -63,6 +78,9 @@ void main() { ); test('rejects a checksum mismatch without replacing an install', () async { + final legacy = Directory('${temporary.path}/buzz/models/pocket-tts/v3'); + await legacy.create(recursive: true); + await File('${legacy.path}/sentinel').writeAsString('January'); final downloader = _downloader( temporary, artifact, @@ -78,12 +96,7 @@ void main() { await downloader.modelDirectory().then((dir) => dir.exists()), isFalse, ); - expect( - await Directory( - '${temporary.path}/buzz/models/pocket-tts', - ).list().isEmpty, - isTrue, - ); + expect(await File('${legacy.path}/sentinel').readAsString(), 'January'); }); test('fails before download when the model and reserve do not fit', () async { @@ -122,7 +135,9 @@ void main() { test('recovers an old install left by an interrupted atomic swap', () async { final parent = Directory('${temporary.path}/buzz/models/pocket-tts'); - final backup = Directory('${parent.path}/.pocket-tts-v3.old-crash'); + final backup = Directory( + '${parent.path}/.pocket-tts-v$pocketModelVersion.old-crash', + ); await backup.create(recursive: true); await File('${backup.path}/sentinel').writeAsString('previous install'); final client = _PendingClient(); @@ -180,21 +195,36 @@ void main() { expect(nextClient, 2); }); - test('license attribution stays identical to the desktop canonical text', () { - final current = Directory.current; - final repository = current.path.endsWith('${Platform.pathSeparator}mobile') - ? current.parent - : current; - final desktopModels = File( - '${repository.path}/desktop/src-tauri/src/huddle/models.rs', - ).readAsStringSync(); - final match = RegExp( - r'const TTS_LICENSE_TEXT: &str = "\\\n([\s\S]*?)\n";', - ).firstMatch(desktopModels); - - expect(match, isNotNull); - final canonical = '${match!.group(1)!.replaceAll(r'\"', '"')}\n'; - expect(pocketModelLicenseText, canonical); + test('April FP32 manifest matches the frozen shared runtime metadata', () { + expect(pocketModelVersion, '4'); + expect(pocketModelRevision, '58a6d00cf13d239b6748cb0769f35c580a8f606c'); + expect(pocketModelPrecision, 'fp32'); + expect( + pocketModelArtifacts + .where((artifact) => artifact.name != 'reference_sample.wav') + .where((artifact) => artifact.name != 'LICENSE') + .fold(0, (total, artifact) => total + artifact.size), + pocketModelCoreBytes, + ); + expect( + pocketModelArtifacts.fold(0, (total, artifact) => total + artifact.size), + pocketModelRuntimeBytes, + ); + expect( + pocketModelArtifacts.map((artifact) => artifact.name), + containsAll([ + 'bundle.json', + 'bos_before_voice.npy', + 'tokenizer.model', + 'flow_lm_main.onnx', + 'flow_lm_flow.onnx', + 'mimi_decoder.onnx', + 'mimi_encoder.onnx', + 'text_conditioner.onnx', + ]), + ); + expect(pocketModelLicenseText, contains('April 2026 ONNX export')); + expect(pocketModelLicenseText, contains(pocketModelRevision)); }); } diff --git a/mobile/test/shared/voice/pocket_voice_controller_test.dart b/mobile/test/shared/voice/pocket_voice_controller_test.dart index f503d54504..e94ef83571 100644 --- a/mobile/test/shared/voice/pocket_voice_controller_test.dart +++ b/mobile/test/shared/voice/pocket_voice_controller_test.dart @@ -29,6 +29,16 @@ void main() { expect(output.played, hasLength(1)); expect(output.played[0].$1, [1, 2]); expect(output.played[0].$2, 24000); + expect( + container.read(pocketVoiceProvider).phase, + PocketVoicePhase.synthesizing, + ); + output.started(); + await _flush(); + expect( + container.read(pocketVoiceProvider).phase, + PocketVoicePhase.speaking, + ); worker.emitAudio(1, [3, 4], isLast: true); await _flush(); @@ -156,6 +166,7 @@ void main() { final state = container.read(pocketVoiceProvider); expect(state.phase, PocketVoicePhase.error); + expect(state.failureKind, PocketVoiceFailureKind.playback); expect(state.error, 'Pocket voice playback failed.'); expect(worker.cancelCount, greaterThan(0)); @@ -164,6 +175,28 @@ void main() { expect(worker.syntheses, [(1, 'Response.')]); expect(container.read(pocketVoiceProvider), same(state)); }); + + test('classifies synthesis failures for platform fallback routing', () async { + final worker = _FakeWorker(); + final output = _FakeAudioOutput(); + final container = _container(worker, output); + addTearDown(container.dispose); + final notifier = container.read(pocketVoiceProvider.notifier); + + await notifier.enable('conversation'); + notifier.speak('conversation', 'Response.'); + worker.emitFailure( + 1, + PocketWorkerFailureKind.synthesis, + 'Pocket synthesis failed.', + ); + await _flush(); + + final state = container.read(pocketVoiceProvider); + expect(state.phase, PocketVoicePhase.error); + expect(state.failureKind, PocketVoiceFailureKind.synthesis); + expect(state.error, 'Pocket synthesis failed.'); + }); } ProviderContainer _container(_FakeWorker worker, _FakeAudioOutput output) => @@ -237,6 +270,14 @@ class _FakeWorker extends PocketVoiceWorker { ); } + void emitFailure( + int generation, + PocketWorkerFailureKind kind, + String message, + ) { + _controller.add(PocketWorkerFailure(kind, message, generation: generation)); + } + @override void cancel() { cancelCount += 1; @@ -274,6 +315,8 @@ class _FakeAudioOutput implements VoiceAudioOutput { void complete() => _controller.add(VoiceAudioEvent.completed); + void started() => _controller.add(VoiceAudioEvent.started); + void fail() => _controller.add(VoiceAudioEvent.error); @override From 4691cad0c0afa153c7a4ce4d2333340e26b3d9c2 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Tue, 28 Jul 2026 09:06:18 -0400 Subject: [PATCH 8/8] fix(mobile): preserve Pocket cancellation and native exports Signed-off-by: John Tennant --- crates/buzz-voice/src/preparation.rs | 23 +++ desktop/src-tauri/src/huddle/tts.rs | 136 +------------ desktop/src-tauri/src/huddle/tts_tests.rs | 180 ------------------ .../pocket_voice_integration_test.dart | 25 ++- .../lib/shared/voice/pocket_voice_worker.dart | 11 +- .../settings/pocket_voice_settings_test.dart | 2 +- scripts/mobile-voice-native.sh | 11 +- 7 files changed, 60 insertions(+), 328 deletions(-) diff --git a/crates/buzz-voice/src/preparation.rs b/crates/buzz-voice/src/preparation.rs index 69fb2c577d..5661a1aad3 100644 --- a/crates/buzz-voice/src/preparation.rs +++ b/crates/buzz-voice/src/preparation.rs @@ -758,4 +758,27 @@ mod tests { assert_eq!(shaped[879], 0.0); assert!(shaped[880..].iter().all(|sample| *sample == 0.0)); } + + #[test] + fn shaping_preserves_the_leading_audio_samples() { + let input: Vec = (0..FADE_OUT_SAMPLES * 4) + .map(|index| 0.5 + index as f32 * 1e-4) + .collect(); + let shaped = shape_tts_chunk(input.clone()); + assert_eq!( + &shaped[LEAD_IN_SAMPLES..LEAD_IN_SAMPLES + FADE_OUT_SAMPLES], + &input[..FADE_OUT_SAMPLES] + ); + } + + #[test] + fn shaping_short_and_empty_audio_remains_well_formed() { + let single = shape_tts_chunk(vec![0.5]); + assert_eq!(single[LEAD_IN_SAMPLES], 0.5); + assert_eq!(single.len(), LEAD_IN_SAMPLES + 1 + TRAILING_SILENCE_SAMPLES); + + let empty = shape_tts_chunk(Vec::new()); + assert_eq!(empty.len(), LEAD_IN_SAMPLES + TRAILING_SILENCE_SAMPLES); + assert!(empty.iter().all(|sample| *sample == 0.0)); + } } diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index bfec9cb05d..3592093ae5 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -48,6 +48,8 @@ use std::{ use super::pocket::{load_text_to_speech, load_voice_style, SAMPLE_RATE, VOICE_FILE_EXT}; use super::preprocessing::{prepare_tts_chunks, shape_tts_chunk}; +#[cfg(test)] +use buzz_voice_pkg::preparation::group_sentences_into_chunks; // ── Constants ───────────────────────────────────────────────────────────────── @@ -69,30 +71,6 @@ const MONITOR_TICK: Duration = Duration::from_millis(10); /// Pocket TTS is a one-step consistency model, not diffusion. Kept for API compat. const SYNTH_STEPS: usize = 1; -/// Fade-out length in samples (8 ms at 24 kHz ≈ 192 samples). -/// -/// Applied only at the *end* of each synthesised sentence to eliminate the -/// click that would otherwise occur when a non-zero waveform terminates -/// abruptly. **No fade-in is applied** — see `apply_fade_out` for the -/// rationale and `examples/pocket_onset_probe.rs` for the measurement that -/// motivated removing the leading fade. -#[cfg(test)] -const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize; - -/// Length of the zero-sample cushion prepended before each synthesized -/// sentence chunk, so the OS audio device / rodio mixer has a fully-quiet -/// ramp-up window before the real onset hits. -/// -/// This used to be applied only before the first sentence of a whole response. -/// That still left later sentence chunks vulnerable to first-syllable clipping -/// when their first phoneme was soft (notably `I'm` / `I've`) and rodio crossed -/// from an explicit silence buffer straight into non-zero speech. 20 ms ≈ 480 -/// samples is enough to cover a CoreAudio buffer turnover without being audible -/// as latency. At sentence boundaries this lead-in is budgeted out of the -/// existing inter-sentence pause, so it does not lengthen multi-sentence gaps. -#[cfg(test)] -const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize; - // ── Public pipeline handle ──────────────────────────────────────────────────── /// Handle to the running TTS pipeline. @@ -397,9 +375,8 @@ fn tts_worker( // whenever the player has fully drained — either in the idle timeout // arm or on item receipt before synthesis begins. // `first_append` = "no audio queued since the player last went idle". - // Flipped by `build_sentence_append_buffer` on the first real append; the - // idle branch below uses it to decide when to drop `tts_active` and to - // arm a fresh lead-in cushion for the next utterance. + // The first successful append clears it; the idle branch below uses it to + // decide when to drop `tts_active`. let mut first_append = true; loop { @@ -606,111 +583,6 @@ fn lock_player_ops(ops: &Mutex<()>) -> MutexGuard<'_, ()> { ops.lock().unwrap_or_else(PoisonError::into_inner) } -/// Hard-clamp samples to ±1.0 full scale. -/// -/// No gain is applied: Pocket TTS already emits speech-level audio -/// (peaks 0.4–0.97, RMS ≈ −20 dBFS across varied sentences — measured by -/// `examples/pocket_clip_probe`), matching the kyutai reference pipeline, -/// which applies no output scaling. Two earlier gain stages were both -/// regressions against that baseline: per-sentence peak normalization caused -/// level pumping between sentences, and the fixed 9.3× gain that replaced it -/// was calibrated on a single anomalously-quiet bench utterance (peak 0.076) -/// and clipped 13–34% of samples on real speech ("blown out", 2026-06-12). -/// The clamp alone remains as the safety net against outlier transients. -#[cfg(test)] -fn clamp_to_full_scale(samples: Vec) -> Vec { - samples.into_iter().map(|s| s.clamp(-1.0, 1.0)).collect() -} - -/// Apply a short linear fade-out at the *end* of `samples`. -/// -/// Uses `FADE_OUT_SAMPLES` (8 ms) or half the buffer length, whichever is -/// smaller. Eliminates the click that occurs when a non-zero waveform -/// terminates abruptly at a sentence boundary. -/// -/// # Why no fade-in -/// -/// An earlier revision (pre 2026-05) symmetrically faded *in* over the same -/// 8 ms window. That swallowed the leading consonant attack on every -/// sentence — Pocket TTS produces real audio energy inside the first -/// millisecond (RMS ≈ 0.02, peak ≈ 0.03 measured across four prompts in -/// `examples/pocket_onset_probe.rs`), and a linear 0→1 ramp over 192 samples -/// scales those onset samples by ≤50 % for the first ~4 ms. The result was -/// the "first little sound or two is missing" regression heard on -/// 2026-05-18. -/// -/// The first sample of Pocket output measures ≈ 0.0018 (≈ −54 dBFS) — well -/// below the threshold at which a DC-jump would be audible as a click — so -/// no fade-in is needed. The OS audio device gets its quiet ramp-up window -/// from `SENTENCE_LEAD_IN_SAMPLES` instead, inserted as pure silence before -/// each sentence buffer. -#[cfg(test)] -fn apply_fade_out(samples: &mut [f32]) { - let len = samples.len(); - let fade = FADE_OUT_SAMPLES.min(len / 2); - for i in 0..fade { - samples[len - 1 - i] *= i as f32 / fade as f32; - } -} - -/// Build the single buffer appended to the rodio `Player` for one synthesised -/// sentence. -/// -/// Every sentence chunk gets a short lead-in pad immediately before its audio. -/// This matters for chunks that start with soft first phonemes (`I'm`, `I've`): -/// the synthesized buffer can begin with speech within the first millisecond, -/// so the playback layer must provide the device/mixer cushion. -/// To keep the audible gap unchanged, the trailing silence after this chunk is -/// shortened by the same amount (`silence_buf_len - SENTENCE_LEAD_IN_SAMPLES`): -/// sentence N contributes 80 ms of post-speech silence and sentence N+1 -/// contributes the remaining 20 ms of pre-speech cushion. -/// -/// The lead-in, audio, and trailing silence are concatenated into one -/// `SamplesBuffer` before appending. This keeps rodio's queue shape at one -/// tracked source per synthesized sentence, avoiding source-boundary/drain -/// regressions from enqueueing the lead-in, audio, and tail as separate sounds. -/// -/// `first_append` is flipped on the first call after the player goes idle. -/// The worker uses it in the idle branch of the main loop to distinguish -/// "never queued anything since last drain" from "drained after speaking", -/// which controls when `tts_active` is released and the lead-in re-armed. -#[cfg(test)] -fn build_sentence_append_buffer( - first_append: &mut bool, - audio: Vec, - silence_buf_len: usize, -) -> Vec { - if *first_append { - *first_append = false; - } - - let trailing_silence_len = silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES); - let mut buf = Vec::with_capacity(SENTENCE_LEAD_IN_SAMPLES + audio.len() + trailing_silence_len); - buf.extend(std::iter::repeat_n(0.0_f32, SENTENCE_LEAD_IN_SAMPLES)); - buf.extend(audio); - buf.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len)); - buf -} - -/// Group sentences into synthesis chunks. -/// -/// The first sentence always stands alone — it is what the listener hears -/// first, and synthesizing it by itself keeps time-to-first-audio at the -/// single-sentence cost. Subsequent sentences pack greedily: a sentence -/// joins the current chunk while the combined length stays within -/// `max_chars`; otherwise it starts a new chunk. A single sentence longer -/// than `max_chars` becomes its own chunk unsplit — Pocket TTS handles long -/// single sentences fine (the ceiling is the 500-LM-step default), it's the -/// *seams* we're minimizing. -/// -/// Sentences within a chunk are joined with a single space; sentence-ending -/// punctuation is preserved by `split_sentences`, so the model sees natural -/// multi-sentence prose — the same shape upstream's ~50-token chunker feeds it. -#[cfg(test)] -fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec { - buzz_voice_pkg::preparation::group_sentences_into_chunks(sentences, max_chars) -} - // drain_until_shutdown lives in super (huddle/mod.rs) — shared with stt.rs. use super::drain_until_shutdown; diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 7887f8bbdb..90d9b147a7 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -736,186 +736,6 @@ fn frames_while_tts_inactive_are_not_counted() { ); } -// ── apply_fade_out tests ────────────────────────────────────────────────── - -/// The fade-out half of the old `apply_fades`: last sample is silenced -/// and the ramp is monotonic. Mid-buffer must be untouched. -#[test] -fn apply_fade_out_short_buffer() { - let mut samples = vec![1.0f32; 10]; - apply_fade_out(&mut samples); - assert_eq!(samples[9], 0.0, "last sample should be silenced"); - assert!(samples[5] > 0.5, "mid-buffer should be near-untouched"); -} - -/// REGRESSION (2026-05-18): the *first* samples must NOT be attenuated. -/// An earlier `apply_fades` symmetrically faded in over 8 ms which -/// swallowed the consonant onset of every sentence. -/// Lock in: samples[0..FADE_OUT_SAMPLES] are byte-equal to input. -#[test] -fn apply_fade_out_does_not_touch_leading_samples() { - // Input long enough that fade window doesn't overlap (≫ 2× fade). - let n = FADE_OUT_SAMPLES * 4; - let input: Vec = (0..n).map(|i| 0.5 + (i as f32) * 1e-4).collect(); - let mut samples = input.clone(); - apply_fade_out(&mut samples); - for i in 0..FADE_OUT_SAMPLES { - assert_eq!( - samples[i], input[i], - "leading sample {i} must not be attenuated (was {} → {})", - input[i], samples[i] - ); - } - // And the trailing fade still works. - assert_eq!(samples[n - 1], 0.0); -} - -#[test] -fn apply_fade_out_empty_buffer() { - let mut samples: Vec = vec![]; - apply_fade_out(&mut samples); - assert!(samples.is_empty()); -} - -#[test] -fn apply_fade_out_single_sample() { - // fade = min(FADE_OUT_SAMPLES, len/2) = 0, so nothing changes. - let mut samples = vec![1.0f32]; - apply_fade_out(&mut samples); - assert_eq!(samples[0], 1.0); -} - -/// Sanity-check the per-sentence cushion length: 20 ms at 24 kHz must -/// land at exactly 480 samples. This is a const computation, so the -/// real value of this test is documenting *why* 20 ms was chosen — it -/// covers a typical CoreAudio buffer turnover (256–1024 samples) -/// without being audible as user-facing latency. -#[test] -fn sentence_lead_in_is_sane() { - assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); -} - -// ── build_sentence_append_buffer tests ─────────────────────────────────── - -/// REGRESSION: every chunk needs an onset cushion; synthesized chunks -/// can start with speech energy within the first millisecond. -#[test] -fn lead_in_pad_is_present_for_every_sentence_chunk() { - const SENTENCE_AUDIO_LEN: usize = 1000; - const SILENCE_BUF_LEN: usize = 2400; // 100 ms at 24 kHz, like production - const N_SENTENCES: usize = 5; - - let mut first = true; - - for _ in 0..N_SENTENCES { - let buf = build_sentence_append_buffer( - &mut first, - vec![0.5_f32; SENTENCE_AUDIO_LEN], - SILENCE_BUF_LEN, - ); - - assert_eq!(buf.len(), SENTENCE_AUDIO_LEN + SILENCE_BUF_LEN); - assert!( - buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0), - "lead-in pad must be pure silence" - ); - assert!( - buf[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + SENTENCE_AUDIO_LEN] - .iter() - .all(|&s| s == 0.5), - "sentence audio must immediately follow the lead-in" - ); - assert!( - buf[SENTENCE_LEAD_IN_SAMPLES + SENTENCE_AUDIO_LEN..] - .iter() - .all(|&s| s == 0.0), - "trailing gap must be pure silence" - ); - } - - assert!(!first, "first_append flag must be cleared after first call"); -} - -/// `first_append` still flips on the first call for `tts_active` gating. -#[test] -fn build_sentence_append_buffer_flips_first_append() { - let mut first = true; - let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); - assert!(!first, "first call must flip the flag"); - - // Subsequent call: still has a per-sentence lead-in, flag stays false. - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); - assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); - assert!(!first); -} - -/// Leading silence is exactly the lead-in; no pre-audio gap is double-counted. -#[test] -fn first_sentence_leading_silence_is_exactly_lead_in() { - let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); - assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); - assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5); -} - -/// Tail silence plus the next lead-in preserves the 100 ms sentence gap. -#[test] -fn sentence_gap_budget_is_preserved() { - let mut first = true; - let silence_buf_len = 2400; - let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len); - let second_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len); - - let first_tail = &first_buf[SENTENCE_LEAD_IN_SAMPLES + 100..]; - let second_lead = &second_buf[..SENTENCE_LEAD_IN_SAMPLES]; - assert_eq!(first_tail.len(), silence_buf_len - SENTENCE_LEAD_IN_SAMPLES); - assert_eq!(second_lead.len(), SENTENCE_LEAD_IN_SAMPLES); - assert_eq!(first_tail.len() + second_lead.len(), silence_buf_len); -} - -/// Regression guard: one contiguous rodio source per synthesized sentence. -#[test] -fn sentence_append_buffer_is_one_contiguous_source() { - let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400); - - assert_eq!(buf.len(), 2400 + 100); - assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); - assert!( - buf[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + 100] - .iter() - .all(|&s| s == 0.5) - ); -} - -// ── clamp_to_full_scale tests ───────────────────────────────────────────── - -/// In-range speech audio passes through bit-exact — no gain is applied. -/// (Pocket output is already at speech level; see the fn doc-comment for -/// the history of the two gain-stage regressions this replaced.) -#[test] -fn clamp_to_full_scale_passes_speech_audio_unchanged() { - let input = vec![0.42_f32, -0.97, 0.076, 0.0]; - let out = clamp_to_full_scale(input.clone()); - assert_eq!(out, input); -} - -/// Outlier transients beyond full scale are hard-clamped to ±1.0 rather -/// than wrapping. -#[test] -fn clamp_to_full_scale_clamps_outliers() { - let input = vec![1.5_f32, -2.0, 0.5]; - let out = clamp_to_full_scale(input); - assert_eq!(out, vec![1.0, -1.0, 0.5]); -} - -/// Empty input round-trips to empty output. -#[test] -fn clamp_to_full_scale_empty_buffer() { - let out = clamp_to_full_scale(Vec::new()); - assert!(out.is_empty()); -} - // ── group_sentences_into_chunks tests ───────────────────────────────────── fn s(v: &[&str]) -> Vec { diff --git a/mobile/integration_test/pocket_voice_integration_test.dart b/mobile/integration_test/pocket_voice_integration_test.dart index 16e6f55307..7f17460402 100644 --- a/mobile/integration_test/pocket_voice_integration_test.dart +++ b/mobile/integration_test/pocket_voice_integration_test.dart @@ -88,9 +88,27 @@ void main() { warmClock.stop(); _expectValidPcm(warm.data.materialize().asUint8List(), warm.sampleRate); - final cancelClock = Stopwatch()..start(); + final queuedCancelClock = Stopwatch()..start(); worker.synthesize( 3, + List.filled( + 20, + 'This queued request must remain cancelled before synthesis begins.', + ).join(' '), + ); + worker.cancel(); + final queuedCancelled = await worker.responses + .where((response) => response is PocketWorkerFailure) + .cast() + .where((failure) => failure.generation == 3) + .first + .timeout(const Duration(seconds: 10)); + queuedCancelClock.stop(); + expect(queuedCancelled.kind, PocketWorkerFailureKind.cancelled); + + final cancelClock = Stopwatch()..start(); + worker.synthesize( + 4, List.filled( 20, 'This deliberately long sentence gives cancellation time to interrupt.', @@ -101,11 +119,11 @@ void main() { final cancelled = await worker.responses .where((response) => response is PocketWorkerFailure) .cast() - .where((failure) => failure.generation == 3) + .where((failure) => failure.generation == 4) .first .timeout(const Duration(seconds: 10)); cancelClock.stop(); - expect(cancelled.message, contains('cancelled')); + expect(cancelled.kind, PocketWorkerFailureKind.cancelled); // Emitted in a stable shape so simulator/device runs are easy to compare. debugPrint( @@ -117,6 +135,7 @@ void main() { 'rtf=${rtf.toStringAsFixed(3)} ' 'playback_ms=${playbackClock.elapsedMilliseconds} ' 'warm_pcm_ms=${warmClock.elapsedMilliseconds} ' + 'queued_cancel_ms=${queuedCancelClock.elapsedMilliseconds} ' 'cancel_ms=${cancelClock.elapsedMilliseconds} ' 'download_ms=${downloadTime?.inMilliseconds ?? 0} ' 'rss_bytes=${ProcessInfo.currentRss} ' diff --git a/mobile/lib/shared/voice/pocket_voice_worker.dart b/mobile/lib/shared/voice/pocket_voice_worker.dart index ea0ca3eca5..54503cfd4a 100644 --- a/mobile/lib/shared/voice/pocket_voice_worker.dart +++ b/mobile/lib/shared/voice/pocket_voice_worker.dart @@ -115,15 +115,21 @@ class PocketVoiceWorker { } void synthesize(int generation, String text) { - if (_commands == null) { + final commands = _commands; + final handle = _handle; + final ffi = _mainFfi; + if (commands == null || handle == null || ffi == null) { throw StateError('Pocket worker is not ready.'); } if (_activeSynthesis != null) { throw StateError('Pocket worker already has an active synthesis.'); } + // Clear cancellation before publishing the command. Any cancel that + // arrives after this point belongs to this generation and must stay set. + ffi.resetCancel(handle); _activeGeneration = generation; _activeSynthesis = Completer(); - _commands!.send(_Synthesize(generation, text)); + commands.send(_Synthesize(generation, text)); } void cancel() { @@ -197,7 +203,6 @@ void _workerMain((SendPort, String) startup) { output.send(PocketWorkerDone(command.generation)); return; } - ffi.resetCancel(handle); for (var index = 0; index < chunks.length; index += 1) { final stopwatch = Stopwatch()..start(); final pcm = ffi.synthesize(handle, chunks[index]); diff --git a/mobile/test/features/settings/pocket_voice_settings_test.dart b/mobile/test/features/settings/pocket_voice_settings_test.dart index a149ff5c73..0f0bbe3b6a 100644 --- a/mobile/test/features/settings/pocket_voice_settings_test.dart +++ b/mobile/test/features/settings/pocket_voice_settings_test.dart @@ -57,7 +57,7 @@ void main() { // Flutter's text rasterization differs between macOS and the Linux CI // runner even with the same bundled fonts. Keep the pixel baseline on the - // platform that produces the reviewer screenshot; the assertions above + // platform that owns the pixel baseline; the assertions above // remain the cross-platform contract for the download state. if (Platform.isMacOS) { await expectLater( diff --git a/scripts/mobile-voice-native.sh b/scripts/mobile-voice-native.sh index 2e760fae5e..17ba2cb417 100755 --- a/scripts/mobile-voice-native.sh +++ b/scripts/mobile-voice-native.sh @@ -218,15 +218,8 @@ build_android_abi() { local destination="${generated_root}/android/jniLibs/${abi}" mkdir -p "$destination" - local shared_library="" - local candidate - for candidate in "${repo_root}/target/${target}/release/deps/"libbuzz_voice_mobile-*.so; do - if [[ -f "$candidate" ]] && - { [[ -z "$shared_library" ]] || [[ "$candidate" -nt "$shared_library" ]]; }; then - shared_library="$candidate" - fi - done - if [[ -z "$shared_library" ]]; then + local shared_library="${repo_root}/target/${target}/release/libbuzz_voice_mobile.so" + if [[ ! -f "$shared_library" ]]; then echo "Android cdylib was not produced for $target" >&2 exit 1 fi