From 008baa71f9dcb454db71cf74500fa789f7fdc229 Mon Sep 17 00:00:00 2001 From: "Mr.Jebelli" Date: Sat, 18 Jul 2026 15:19:21 +0330 Subject: [PATCH] ai voice chat edit --- .gitignore | 4 + .../kotlin/com/example/didvan/MainActivity.kt | 64 + android/build.gradle | 15 +- lib/shaders/ai_orb_3d.frag | 296 +++ lib/views/ai/ai_chat_page.dart | 19 +- lib/views/home/main/main_page.dart | 40 +- lib/views/home/main/main_page_state.dart | 9 + .../main/widgets/simple_explore_card.dart | 5 + .../search/widgets/search_result_item.dart | 10 + .../news/news_details/news_details_state.dart | 14 +- .../radar_details/radar_details_state.dart | 11 +- lib/views/widgets/ai_voice_chat_dialog.dart | 2297 +++++++++++++++-- pubspec.yaml | 1 + 13 files changed, 2539 insertions(+), 246 deletions(-) create mode 100644 lib/shaders/ai_orb_3d.frag diff --git a/.gitignore b/.gitignore index 976a66d..7ca9b92 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ android/app/.cxx android/app/.cxx/Debug +# Graphify generated output +/graphify-out/ + + # Miscellaneous *.class *.log diff --git a/android/app/src/main/kotlin/com/example/didvan/MainActivity.kt b/android/app/src/main/kotlin/com/example/didvan/MainActivity.kt index 0dd0cc0..c40b4bc 100644 --- a/android/app/src/main/kotlin/com/example/didvan/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/didvan/MainActivity.kt @@ -3,10 +3,12 @@ package com.didvan.didvanapp import android.content.ContentValues import android.content.Context import android.content.Intent +import android.media.AudioManager import android.os.Build import android.os.Bundle import android.provider.MediaStore import android.util.Log +import android.view.KeyEvent import androidx.annotation.NonNull import io.flutter.embedding.android.FlutterActivity import io.flutter.embedding.engine.FlutterEngine @@ -17,10 +19,36 @@ import java.io.FileInputStream class MainActivity: FlutterActivity() { private val CHANNEL = "com.didvan.shareFile" + private val AUDIO_EVENTS_CHANNEL = "com.didvan.didvanapp/audio_events" + private var audioEventsChannel: MethodChannel? = null + private var outputProbePreviousVolume: Int? = null @Override override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) + + audioEventsChannel = MethodChannel( + flutterEngine.dartExecutor.binaryMessenger, + AUDIO_EVENTS_CHANNEL + ) + audioEventsChannel?.setMethodCallHandler { call, result -> + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + when (call.method) { + "beginOutputProbe" -> { + if (outputProbePreviousVolume == null) { + outputProbePreviousVolume = + audioManager.getStreamVolume(AudioManager.STREAM_MUSIC) + } + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, 0, 0) + result.success(null) + } + "endOutputProbe" -> { + restoreOutputProbeVolume(audioManager) + result.success(null) + } + else -> result.notImplemented() + } + } MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result -> when (call.method) { @@ -45,6 +73,42 @@ class MainActivity: FlutterActivity() { } } + override fun dispatchKeyEvent(event: KeyEvent): Boolean { + if ( + event.keyCode == KeyEvent.KEYCODE_VOLUME_UP || + event.keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + event.keyCode == KeyEvent.KEYCODE_VOLUME_MUTE + ) { + // Do not consume the key: Android must still change the volume. + // One physical press produces DOWN, repeat and UP events. Notify + // Dart only once so a held key cannot continuously restart its + // acoustic-path guard. + if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) { + audioEventsChannel?.invokeMethod( + "volumeChanged", + mapOf( + "keyCode" to event.keyCode + ) + ) + } + } + return super.dispatchKeyEvent(event) + } + + override fun onDestroy() { + val audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + restoreOutputProbeVolume(audioManager) + audioEventsChannel?.setMethodCallHandler(null) + audioEventsChannel = null + super.onDestroy() + } + + private fun restoreOutputProbeVolume(audioManager: AudioManager) { + val previousVolume = outputProbePreviousVolume ?: return + audioManager.setStreamVolume(AudioManager.STREAM_MUSIC, previousVolume, 0) + outputProbePreviousVolume = null + } + private fun copyFileToDownloads(filePath: String, fileName: String): Boolean { return try { val context: Context = applicationContext diff --git a/android/build.gradle b/android/build.gradle index 48c1797..14fa100 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,16 +1,3 @@ -buildscript { - ext.kotlin_version = '1.9.0' // (ممکن است ورژن کاتلین شما متفاوت باشد، به آن دست نزنید) - repositories { - google() // حتماً خط اول باشد - mavenCentral() // حتماً خط دوم باشد - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.3.0' // (ورژن گریدل شما) - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - allprojects { repositories { google() // حتماً خط اول باشد @@ -42,4 +29,4 @@ subprojects { force 'androidx.activity:activity-ktx:1.9.3' } } -} \ No newline at end of file +} diff --git a/lib/shaders/ai_orb_3d.frag b/lib/shaders/ai_orb_3d.frag new file mode 100644 index 0000000..84ca858 --- /dev/null +++ b/lib/shaders/ai_orb_3d.frag @@ -0,0 +1,296 @@ +#include + +uniform vec2 uResolution; +uniform float uTime; +uniform float uActivity; +uniform vec3 uAccent; +uniform float uTransient; +uniform float uTexture; +uniform float uMotionPhase; + +out vec4 fragColor; + +const float PI = 3.14159265359; + +float saturate(float value) { + return clamp(value, 0.0, 1.0); +} + +float hash21(vec2 point) { + point = fract(point * vec2(123.34, 456.21)); + point += dot(point, point + 45.32); + return fract(point.x * point.y); +} + +float softNoise(vec2 point) { + vec2 cell = floor(point); + vec2 local = fract(point); + local = local * local * (3.0 - 2.0 * local); + return mix( + mix(hash21(cell), hash21(cell + vec2(1.0, 0.0)), local.x), + mix(hash21(cell + vec2(0.0, 1.0)), hash21(cell + 1.0), local.x), + local.y + ); +} + +float softBox(vec2 point, vec2 halfSize, float feather) { + vec2 distance = abs(point) - halfSize; + return 1.0 - smoothstep( + 0.0, + feather, + max(distance.x, distance.y) + ); +} + +float directionalLobe(vec2 radial, vec2 direction, float sharpness) { + return pow(saturate(dot(radial, normalize(direction))), sharpness); +} + +vec3 studioEnvironment(vec3 direction) { + float skyMix = smoothstep(-0.55, 0.72, direction.y); + vec3 floorColor = vec3(0.012, 0.016, 0.028); + vec3 skyColor = vec3(0.36, 0.46, 0.68); + vec3 environmentColor = mix(floorColor, skyColor, skyMix); + + float horizonStrip = pow( + saturate(1.0 - abs(direction.y + 0.09) * 4.8), + 10.0 + ); + environmentColor += vec3(0.68, 0.78, 1.0) * horizonStrip * 0.42; + + float keyPanel = pow( + saturate(dot(direction, normalize(vec3(-0.69, -0.28, 0.67)))), + 24.0 + ); + environmentColor += vec3(0.93, 0.96, 1.0) * keyPanel * 0.72; + + float rimPanel = pow( + saturate(dot(direction, normalize(vec3(0.76, 0.04, 0.65)))), + 34.0 + ); + environmentColor += vec3(0.34, 0.52, 1.0) * rimPanel * 0.48; + + float warmPanel = pow( + saturate(dot(direction, normalize(vec3(0.42, 0.58, 0.69)))), + 42.0 + ); + environmentColor += vec3(0.76, 0.49, 0.31) * warmPanel * 0.16; + return environmentColor; +} + +void main() { + vec2 fragCoord = FlutterFragCoord().xy; + vec2 centered = fragCoord - uResolution * 0.5; + vec2 uv = centered / min(uResolution.x, uResolution.y); + + float activity = saturate(uActivity); + float transientEnergy = saturate(uTransient); + float textureEnergy = saturate(uTexture); + float timePhase = uTime * PI * 2.0; + float voiceEnergy = smoothstep(0.045, 0.88, activity); + float radius = 0.326 + voiceEnergy * 0.0015; + float distanceToCenter = length(uv); + float safeDistance = max(distanceToCenter, 0.0001); + vec2 radial = uv / safeDistance; + float distanceFromSurface = distanceToCenter - radius; + + // Reflections around the silhouette are directional rather than a uniform + // neon ring. They read as large studio lights caught by a glass surface. + float perimeterBand = exp(-abs(distanceFromSurface) * 72.0); + float upperLeftArc = directionalLobe( + radial, + vec2(-0.72, -0.69), + 8.0 + ); + float rightArc = directionalLobe(radial, vec2(0.96, -0.12), 13.0); + float lowerArc = directionalLobe(radial, vec2(0.28, 0.96), 18.0); + float arcBreath = 0.92 + 0.08 * sin( + uMotionPhase * 0.72 + textureEnergy * 1.8 + ); + vec3 perimeterReflection = + vec3(0.88, 0.93, 1.0) * upperLeftArc * 0.38 + + mix(uAccent, vec3(0.48, 0.68, 1.0), 0.48) * rightArc * 0.3 + + vec3(0.95, 0.58, 0.38) * lowerArc * 0.075; + perimeterReflection *= perimeterBand * arcBreath; + + float halo = exp(-max(distanceFromSurface, 0.0) * 15.0); + halo *= 1.0 - smoothstep(radius, radius * 2.42, distanceToCenter); + vec3 haloColor = uAccent * halo * ( + 0.025 + voiceEnergy * 0.055 + transientEnergy * 0.025 + ); + + // A soft contact shadow and a dim, compressed floor reflection ground the + // sphere in space. Voice changes the reflection, not its physical position. + vec2 shadowUv = vec2( + uv.x / (radius * 1.08), + (uv.y - radius * 1.12) / (radius * 0.18) + ); + float contactShadow = exp( + -(shadowUv.x * shadowUv.x * 2.3 + shadowUv.y * shadowUv.y * 1.55) + ); + float reflectionBreakup = 0.72 + 0.28 * sin( + shadowUv.x * 7.0 + uMotionPhase * 0.36 + ); + float floorReflection = contactShadow * reflectionBreakup * + (0.12 + voiceEnergy * 0.1); + vec3 outsideColor = + haloColor + + perimeterReflection + + uAccent * floorReflection * 0.16 + + vec3(0.018, 0.022, 0.036) * contactShadow * 0.62; + float outsideAlpha = saturate( + halo * 0.17 + + perimeterBand * (upperLeftArc * 0.42 + rightArc * 0.34) + + contactShadow * 0.34 + ); + + if (distanceToCenter > radius) { + fragColor = vec4(outsideColor, outsideAlpha); + return; + } + + float z = sqrt(max(radius * radius - dot(uv, uv), 0.0)); + vec3 geometricNormal = normalize(vec3(uv / radius, z / radius)); + + // Microscopic normal variation gives the material thickness without + // visibly deforming the sphere. Speech texture subtly increases it. + vec2 noiseCoordinate = + geometricNormal.xy * 5.1 + + vec2(timePhase * 0.025, -timePhase * 0.018); + float noiseA = softNoise(noiseCoordinate); + float noiseB = softNoise(noiseCoordinate * 1.73 + vec2(2.7, -1.9)); + vec2 microNormal = vec2(noiseA - 0.5, noiseB - 0.5) * + (0.0035 + textureEnergy * 0.009); + vec3 normal = normalize(vec3( + geometricNormal.xy + microNormal, + geometricNormal.z + )); + + vec3 viewDirection = vec3(0.0, 0.0, 1.0); + vec3 keyDirection = normalize(vec3( + -0.48 + sin(uMotionPhase * 0.31) * voiceEnergy * 0.012, + -0.63 + cos(uMotionPhase * 0.27) * voiceEnergy * 0.009, + 0.88 + )); + vec3 fillDirection = normalize(vec3(0.76, 0.16, 0.54)); + float diffuse = max(dot(normal, keyDirection), 0.0); + float fill = max(dot(normal, fillDirection), 0.0); + float ndv = saturate(dot(normal, viewDirection)); + float fresnel = 0.035 + 0.965 * pow(1.0 - ndv, 5.0); + float thickness = (2.0 * z) / radius; + + vec3 reflectedDirection = normalize(reflect(-viewDirection, normal)); + vec3 reflection = studioEnvironment(reflectedDirection); + + // Three close indices of refraction create restrained chromatic dispersion + // at the edge, a key cue for real optical glass. + vec3 refractedRed = studioEnvironment(normalize( + refract(-viewDirection, normal, 0.695) + )); + vec3 refractedGreen = studioEnvironment(normalize( + refract(-viewDirection, normal, 0.715) + )); + vec3 refractedBlue = studioEnvironment(normalize( + refract(-viewDirection, normal, 0.738) + )); + vec3 refractedEnvironment = vec3( + refractedRed.r, + refractedGreen.g, + refractedBlue.b + ); + + vec3 absorption = vec3(0.19, 0.13, 0.075); + vec3 transmission = exp(-absorption * thickness); + vec3 deepTint = mix(vec3(0.008, 0.012, 0.024), uAccent * 0.12, 0.38); + vec3 color = deepTint * (0.2 + diffuse * 0.16 + fill * 0.04); + color += refractedEnvironment * transmission * (0.15 + ndv * 0.08); + color += reflection * (0.22 + fresnel * 1.04); + color += uAccent * fresnel * (0.035 + voiceEnergy * 0.065); + + // Coherent rectangular reflections sell the object as a glossy 3D sphere. + float surfaceVisibility = smoothstep(0.12, 0.7, geometricNormal.z); + float keySoftbox = softBox( + normal.xy - vec2(-0.34, -0.3), + vec2(0.055, 0.235), + 0.055 + ) * surfaceVisibility; + float topSoftbox = softBox( + normal.xy - vec2(0.05, -0.49), + vec2(0.27, 0.035), + 0.06 + ) * surfaceVisibility; + float sideSoftbox = softBox( + normal.xy - vec2(0.56, 0.03), + vec2(0.026, 0.19), + 0.048 + ) * surfaceVisibility; + color += vec3(0.91, 0.95, 1.0) * keySoftbox * 0.19; + color += vec3(0.72, 0.82, 1.0) * topSoftbox * 0.08; + color += mix(uAccent, vec3(0.5, 0.7, 1.0), 0.52) * + sideSoftbox * 0.13; + + vec3 halfVector = normalize(keyDirection + viewDirection); + float sharpSpecular = pow(max(dot(normal, halfVector), 0.0), 180.0); + color += vec3(1.0, 0.98, 0.95) * sharpSpecular * 1.55; + vec3 broadHalf = normalize(vec3(-0.58, -0.4, 0.72) + viewDirection); + float broadSpecular = pow(max(dot(normal, broadHalf), 0.0), 25.0); + color += vec3(0.72, 0.82, 1.0) * broadSpecular * 0.1; + + // Keep the optical center dark and clean. Speech travels through the glass + // as thin refracted sheets instead of an artificial glowing blob. + vec2 normalizedUv = uv / radius; + float internalDepth = + 1.0 - smoothstep(0.12, 0.98, length(normalizedUv)); + float cleanCenter = pow(internalDepth, 1.65); + color *= 1.0 - cleanCenter * 0.115; + + float voicePulse = 0.82 + 0.18 * sin( + uMotionPhase * 1.23 + textureEnergy * 1.6 + ); + float primarySheetY = + normalizedUv.y + + normalizedUv.x * 0.11 + + 0.13 - + sin(uMotionPhase * 0.54) * voiceEnergy * 0.055; + float primarySheet = exp(-primarySheetY * primarySheetY * 82.0) * + exp(-dot(normalizedUv, normalizedUv) * 1.15); + float secondarySheetY = + normalizedUv.y - + normalizedUv.x * 0.07 - + 0.42 + + cos(uMotionPhase * 0.39) * voiceEnergy * 0.035; + float secondarySheet = exp( + -secondarySheetY * secondarySheetY * 115.0 + ) * exp(-dot(normalizedUv, normalizedUv) * 1.5); + color += mix(uAccent, vec3(0.66, 0.8, 1.0), 0.38) * + primarySheet * voiceEnergy * voicePulse * 0.052; + color += vec3(0.68, 0.8, 1.0) * secondarySheet * + voiceEnergy * 0.026; + + float onsetFlash = pow(1.0 - ndv, 2.2) * + upperLeftArc * transientEnergy; + color += vec3(0.86, 0.92, 1.0) * onsetFlash * 0.18; + + float causticPhase = + normalizedUv.x * 14.0 + + normalizedUv.y * 9.0 - + uMotionPhase * (0.52 + textureEnergy * 0.38) + + noiseA * 2.4; + float caustic = pow(saturate(0.5 + 0.5 * sin(causticPhase)), 9.0); + float causticBand = smoothstep(0.24, 0.52, length(normalizedUv)) * + (1.0 - smoothstep(0.78, 0.97, length(normalizedUv))); + caustic *= causticBand * voiceEnergy * (0.25 + textureEnergy * 0.75); + color += mix(uAccent, vec3(0.78, 0.88, 1.0), 0.58) * + caustic * 0.045; + + // Bring the outside light sources continuously across the glass boundary. + color += perimeterReflection * (0.56 + fresnel * 0.64); + color *= mix(0.88, 1.025, geometricNormal.z); + + float edgeAlpha = 1.0 - smoothstep( + radius * 0.982, + radius, + distanceToCenter + ); + fragColor = vec4(color, edgeAlpha); +} diff --git a/lib/views/ai/ai_chat_page.dart b/lib/views/ai/ai_chat_page.dart index 0293cd3..3ca4c25 100644 --- a/lib/views/ai/ai_chat_page.dart +++ b/lib/views/ai/ai_chat_page.dart @@ -1109,7 +1109,18 @@ class _AiChatPageState extends State with TickerProviderStateMixin { ) : Column( children: [ - if (file != null) + if (file != null && file.path.isEmpty) + // پیام‌های قدیمی که تولیدشان ناموفق بود file + // خالی دارند — پخش/دانلود ممکن نیست. + const Padding( + padding: EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + child: DidvanText( + 'فایل در دسترس نیست. لطفاً دوباره تولید کنید.', + fontSize: 12, + ), + ) + else if (file != null) (file.isAudio()) ? Padding( padding: const EdgeInsets.symmetric( @@ -1157,12 +1168,14 @@ class _AiChatPageState extends State with TickerProviderStateMixin { child: messageFile( context, message, state), ), + // متنِ همراهِ عکس هم باید نمایش داده شود — + // شرط قبلی (!file.isImage()) متنِ کاربر را + // وقتی عکس پیوست بود مخفی می‌کرد. if (message.text != null && message.text!.isNotEmpty && ((message.audio == null || (message.audio != null && - !message.audio!))) && - (file == null || !file.isImage())) + !message.audio!)))) Padding( padding: const EdgeInsets.symmetric( vertical: 8.0, horizontal: 16), diff --git a/lib/views/home/main/main_page.dart b/lib/views/home/main/main_page.dart index 6cd2864..4872852 100644 --- a/lib/views/home/main/main_page.dart +++ b/lib/views/home/main/main_page.dart @@ -386,6 +386,10 @@ class _ExploreLatestSlider extends StatelessWidget { data.content!.link ?? '', description: data.content!.title, file: fileString, + // Pass new-API UUID so news/radar details use + // getContentDetail instead of the legacy endpoint, which + // fails with "اتصال اینترنت برقرار نیست" for these ids. + contentUuid: data.content!.contentId, ); } }, @@ -540,11 +544,21 @@ class _ExploreLatestSliderNew extends StatelessWidget { onItemTap: (index) { if (index < contentSlice.length) { final item = contentSlice[index]; + // Route to the correct details page based on the content's + // metadata type. 'content' as a nav type isn't handled by + // navigationHandler and produces the "اتصال اینترنت برقرار + // نیست" empty state. + final metaType = (item.metaType ?? '').toLowerCase(); + final navType = + (metaType == 'threat' || metaType == 'opportunity') + ? 'radar' + : 'news'; context.read().navigationHandler( - 'content', - item.id, + navType, + item.id.hashCode, null, description: item.title, + contentUuid: item.id, ); } }, @@ -662,12 +676,22 @@ class _NewContentCard extends StatelessWidget { margin: const EdgeInsets.symmetric(vertical: 6), child: InkWell( borderRadius: DesignConfig.mediumBorderRadius, - onTap: () => context.read().navigationHandler( - 'content', - item.id, - null, - description: item.title, - ), + onTap: () { + // See _ExploreLatestSliderNew — route by metaType, pass UUID so + // details uses the new-API path. + final metaType = (item.metaType ?? '').toLowerCase(); + final navType = + (metaType == 'threat' || metaType == 'opportunity') + ? 'radar' + : 'news'; + context.read().navigationHandler( + navType, + item.id.hashCode, + null, + description: item.title, + contentUuid: item.id, + ); + }, child: Padding( padding: const EdgeInsets.all(12), child: Column( diff --git a/lib/views/home/main/main_page_state.dart b/lib/views/home/main/main_page_state.dart index 96314b3..2bf1b71 100644 --- a/lib/views/home/main/main_page_state.dart +++ b/lib/views/home/main/main_page_state.dart @@ -931,6 +931,7 @@ class MainPageState extends CoreProvier { String? link, { String? description, String? file, + String? contentUuid, }) { link = link ?? ''; final context = navigatorKey.currentContext; @@ -951,6 +952,12 @@ class MainPageState extends CoreProvier { Navigator.of(context).pushNamed(Routes.newsDetails, arguments: { 'onMarkChanged': (id, value) => markChangeHandler(type, id, value), 'id': id is int ? id : 0, + // New-API UUID: forces getContentDetail path in details state. + // Without this, details tries the legacy endpoint with the + // hashCode-derived id, gets a network/404 failure, and the page + // shows "اتصال اینترنت برقرار نیست". + if (contentUuid != null && contentUuid.isNotEmpty) + 'contentUuid': contentUuid, 'args': const NewsRequestArgs(page: 0), 'hasUnmarkConfirmation': false, 'description': description, @@ -960,6 +967,8 @@ class MainPageState extends CoreProvier { Navigator.of(context).pushNamed(Routes.radarDetails, arguments: { 'onMarkChanged': (id, value) => markChangeHandler(type, id, value), 'id': id is int ? id : 0, + if (contentUuid != null && contentUuid.isNotEmpty) + 'contentUuid': contentUuid, 'args': const RadarRequestArgs(page: 0), 'hasUnmarkConfirmation': false, 'description': description, diff --git a/lib/views/home/main/widgets/simple_explore_card.dart b/lib/views/home/main/widgets/simple_explore_card.dart index d2f7d6d..4936403 100644 --- a/lib/views/home/main/widgets/simple_explore_card.dart +++ b/lib/views/home/main/widgets/simple_explore_card.dart @@ -85,6 +85,11 @@ class SimpleExploreCard extends StatelessWidget { content!.link ?? '', file: content!.file, description: content!.title, + // Forward the new-API UUID so news/radar details opens via + // getContentDetail. Without this the SimpleExploreCard tap + // fell into the legacy `/news/` path and showed + // the "اتصال اینترنت برقرار نیست" empty state. + contentUuid: content!.contentId, ); } }, diff --git a/lib/views/home/search/widgets/search_result_item.dart b/lib/views/home/search/widgets/search_result_item.dart index 973b4e8..73d74cd 100644 --- a/lib/views/home/search/widgets/search_result_item.dart +++ b/lib/views/home/search/widgets/search_result_item.dart @@ -154,7 +154,17 @@ class SearchResultItem extends StatelessWidget { _targetPageRouteName!, arguments: { 'id': item.id, + // Pass the new-API UUID so news/radar details uses + // getContentDetail (new API) instead of getNewsDetails (legacy) + // — the legacy endpoint doesn't know these hashCode-derived + // ids and returns 404, showing the "تلاش مجدد" retry state. + if (item.keycloakId != null && item.keycloakId!.isNotEmpty) + 'contentUuid': item.keycloakId, 'args': _targetPageArgs, + // Details pages call these unconditionally — pass no-ops so + // they don't crash / fall into an error state. + 'onMarkChanged': (int _, bool __) {}, + 'hasUnmarkConfirmation': false, }, ); } diff --git a/lib/views/news/news_details/news_details_state.dart b/lib/views/news/news_details/news_details_state.dart index 42cb9fe..cf54f5e 100644 --- a/lib/views/news/news_details/news_details_state.dart +++ b/lib/views/news/news_details/news_details_state.dart @@ -107,9 +107,10 @@ class NewsDetailsState extends CoreProvier { _currentIndex--; } isFetchingNewItem = false; - if (currentNews.contents.length == 1) { - getRelatedContents(); - } + // Always fetch related content — the previous + // `contents.length == 1` gate was a legacy quirk that hid the + // "مطالب مرتبط" section for almost every news item. + getRelatedContents(); appState = AppState.idle; } else { isFetchingNewItem = false; @@ -405,13 +406,16 @@ class NewsDetailsState extends CoreProvier { _markRelatedEmpty(); return; } - // امتیازدهی بر اساس تعداد تگ‌های مشترک. + // Backend already filtered by tag name — don't re-filter here. + // Persian text normalization (Arabic vs Persian YEH, ZWNJ) makes + // exact string equality unreliable and would drop legitimate matches. + // Rank by shared-tag count when we can, but keep everything else. final tagSet = tagNames.toSet(); final scored = >[]; for (final c in res.data!.data) { if (c.id.hashCode == currentNews.id) continue; final shared = c.tagNames.where(tagSet.contains).length; - if (shared > 0) scored.add(MapEntry(shared, c)); + scored.add(MapEntry(shared, c)); } scored.sort((a, b) => b.key.compareTo(a.key)); final target = news.firstWhere((e) => e?.id == currentNews.id); diff --git a/lib/views/radar/radar_details/radar_details_state.dart b/lib/views/radar/radar_details/radar_details_state.dart index aef9368..d334b70 100644 --- a/lib/views/radar/radar_details/radar_details_state.dart +++ b/lib/views/radar/radar_details/radar_details_state.dart @@ -117,9 +117,9 @@ class RadarDetailsState extends CoreProvier { _currentIndex--; } isFetchingNewItem = false; - if (currentRadar.contents.length == 1) { - getRelatedContents(); - } + // Always fetch related content — see news_details_state for the + // same fix. contents.length gate was hiding مطالب مرتبط. + getRelatedContents(); appState = AppState.idle; } else { isFetchingNewItem = false; @@ -197,12 +197,15 @@ class RadarDetailsState extends CoreProvier { _markRelatedEmpty(); return; } + // Backend already filtered by tag name — don't re-filter here. + // Persian text normalization (Arabic vs Persian YEH, ZWNJ) makes + // exact string equality unreliable and would drop legitimate matches. final tagSet = tagNames.toSet(); final scored = >[]; for (final c in res.data!.data) { if (c.id.hashCode == currentRadar.id) continue; final shared = c.tagNames.where(tagSet.contains).length; - if (shared > 0) scored.add(MapEntry(shared, c)); + scored.add(MapEntry(shared, c)); } scored.sort((a, b) => b.key.compareTo(a.key)); final target = radars.firstWhere((e) => e?.id == currentRadar.id); diff --git a/lib/views/widgets/ai_voice_chat_dialog.dart b/lib/views/widgets/ai_voice_chat_dialog.dart index 5710fdd..0e2e6ce 100644 --- a/lib/views/widgets/ai_voice_chat_dialog.dart +++ b/lib/views/widgets/ai_voice_chat_dialog.dart @@ -10,8 +10,8 @@ // 3) ارسال session.update با voice=leo + instructions فارسی + server VAD // 4) Mic capture با PCM16 mono 24kHz → base64 → input_audio_buffer.append // 5) Audio playback با flutter_sound feedFromStream در 24kHz PCM -// 6) Server VAD: input_audio_buffer.speech_started → playback را قطع کن + -// response.cancel بفرست +// 6) Server VAD: input_audio_buffer.speech_started وقتی بلندگو فعال نیست → +// اینتراپت؛ وقتی بلندگو فعال است (اکو) → فقط input_audio_buffer.clear // // Voice: 'leo' — صدای مردانه. می‌توان به Eve/Ara/Rex/Sal تغییر داد. @@ -20,18 +20,18 @@ import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; -import 'dart:ui'; +import 'dart:ui' as ui; import 'package:audio_session/audio_session.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:mp_audio_stream/mp_audio_stream.dart'; import 'package:record/record.dart'; import 'package:web_socket_channel/io.dart'; import 'package:didvan/services/network/request.dart'; import 'package:didvan/services/network/request_helper.dart'; -import 'package:didvan/views/widgets/didvan/text.dart'; class _QueuedPcmChunk { const _QueuedPcmChunk(this.samples, this.generation); @@ -40,6 +40,35 @@ class _QueuedPcmChunk { final int generation; } +class _PlaybackReferenceChunk { + const _PlaybackReferenceChunk({ + required this.samples, + required this.startsAt, + required this.endsAt, + }); + + final Float32List samples; + final DateTime startsAt; + final DateTime endsAt; +} + +class _VoiceMessage { + const _VoiceMessage(this.text); + + final String text; +} + +enum _VoiceVisualState { + connecting, + listening, + hearing, + searching, + thinking, + speaking, + muted, + error, +} + class AiVoiceChatDialog extends StatefulWidget { const AiVoiceChatDialog({super.key}); @@ -49,24 +78,83 @@ class AiVoiceChatDialog extends StatefulWidget { class _AiVoiceChatDialogState extends State with TickerProviderStateMixin { + static const MethodChannel _audioEventsChannel = + MethodChannel('com.didvan.didvanapp/audio_events'); // ----- xAI realtime config ---------------------------------------- static const String _wsUrl = 'wss://api.x.ai/v1/realtime?model=grok-voice-latest'; static const String _voice = 'leo'; + // ⚠️ 24 kHz — must match what xAI's realtime API accepts. A 16 kHz + // experiment broke session.update entirely (session.updated never came + // back → mic audio was buffered forever → AI silent). Don't change + // without verifying the session handshake still completes. static const int _sampleRate = 24000; static const int _playerBufferMillis = 30000; - static const double _bargeInRmsThreshold = 0.08; - static const int _bargeInMinLoudChunks = 2; - static const int _bargeInMinLoudMilliseconds = 120; - static const String _instructions = - 'تو هوشان هستی، دستیار هوشمند دیدوان. همیشه فقط به زبان فارسی صحبت کن ' - 'و هرگز از زبان دیگری استفاده نکن. مفید، دوستانه و حرفه‌ای باش. ' - 'پاسخ‌های واضح و مختصر بده. در مورد فعالیت‌های غیرقانونی کمک نکن، ' - 'مشاوره مضر نده، و اگر سؤالی شامل اطلاعات شخصی حساس یا موضوع پیچیده‌ای ' - 'فراتر از توانایی تو بود، کاربر را به یک انسان ارجاع بده. درخواست‌ها ' - 'را به صورت طبیعی تأیید کن و مکالمه‌گونه پاسخ بده. ' - 'وقتی سوال کاربر نیاز به داده‌ی به‌روز، منبع بیرونی یا واقعیات لحظه‌ای دارد ' - 'حتماً از web_search یا x_search استفاده کن و پاسخ را فقط بعد از گرفتن نتایج کامل کن.'; + static const int _initialJitterBufferMilliseconds = 260; + static const int _nativeRecoveryBufferMilliseconds = 10; + static const int _playbackDrainPaddingMilliseconds = 20; + static const double _playbackGain = 1.35; + // Barge-in tuning. Loose enough that a normally-spoken word or two + // interrupts, strict enough that AI's own speaker echo doesn't. The + // echo-cooldown safety net covers the leftover cases where echo does + // sneak through — even a false barge-in gets scrubbed by the + // input_audio_buffer.clear inside _interruptAi. + // Android AEC attenuates the user's own voice hard while the speaker is + // active — measured real speech lands around 0.02–0.04 RMS. The old floor + // (0.055) was physically unreachable for most users, so voice barge-in + // never fired. Echo rejection is the correlation gate's responsibility, + // not a high RMS floor. + // Measured on the user's device: speaker echo during playback lands at + // 0.006–0.011 RMS, open-mic speech at ~0.066. AEC ducks the user's voice + // during playback to roughly 0.015–0.03, so the floor sits between the + // echo band and the ducked-voice band. Correlated echo is filtered + // separately by _playbackEchoCorrelation. + // ⚠️ Measured reality on this hardware at raised volume: speaker echo + // reaches rms 0.03–0.094 and speaker distortion destroys waveform + // correlation (0.10–0.51 on pure echo), so energy alone can never separate + // a quiet user from loud echo. The floor is therefore deliberately LOW: + // candidates are cheap, and the final decision belongs to the + // output-ducking probe — echo dies when the speaker is muted, a real + // user (even a quiet one) keeps talking. + // This is a probe trigger, not a final voice decision. Speaker echo and an + // AEC-ducked nearby user overlap on this device, so energy alone must never + // execute an interrupt. + static const double _bargeInRmsThreshold = 0.012; + static const int _bargeInMinLoudChunks = 5; + static const int _bargeInMinLoudMilliseconds = 350; + static const int _bargeInProbeTriggerChunks = 3; + static const int _bargeInProbeTriggerMilliseconds = 160; + static const int _bargeInProbeEchoTailMilliseconds = 180; + static const int _bargeInProbeDurationMilliseconds = 460; + static const int _bargeInProbeRequiredVoiceChunks = 2; + static const double _bargeInProbeVoiceFloor = 0.009; + // Wait this long after AI starts speaking before ARMING barge-in so + // echo baseline stabilizes on the AI's own voice first. + static const int _bargeInArmDelayMilliseconds = 400; + static const int _volumeGainSettlingMilliseconds = 1400; + static const int _naturalPlaybackEchoTailMilliseconds = 500; + static const int _postInterruptEchoTailMilliseconds = 120; + static const double _strongEchoCorrelation = 0.72; + static const double _possibleEchoCorrelation = 0.50; + String get _instructions { + final now = DateTime.now(); + final today = '${now.year.toString().padLeft(4, '0')}-' + '${now.month.toString().padLeft(2, '0')}-' + '${now.day.toString().padLeft(2, '0')}'; + return 'تو هوشان هستی، دستیار هوشمند دیدوان. همیشه فقط به زبان فارسی صحبت کن ' + 'و هرگز از زبان دیگری استفاده نکن. مفید، دوستانه و حرفه‌ای باش. ' + 'پاسخ‌های واضح و مختصر بده. تاریخ امروز $today و منطقه زمانی کاربر Asia/Tehran است. ' + 'برای هر پرسش اطلاعاتی یا واقعیت‌محور، مخصوصاً خبر، قیمت، آب‌وهوا، افراد، ' + 'شرکت‌ها، سیاست، ورزش، آمار، زمان‌بندی و هر موضوعی که ممکن است تغییر کرده باشد، ' + 'قبل از پاسخ حتماً web_search را اجرا کن و فقط بر اساس نتیجه جست‌وجو جواب بده. ' + 'برای اطلاعات شبکه X در صورت نیاز x_search را هم اجرا کن. هرگز درباره اطلاعات ' + 'به‌روز از حافظه حدس نزن و هرگز ادعا نکن که به وب دسترسی نداری. ' + 'اگر متن تشخیص‌داده‌شده از صدای کاربر مبهم، ناقص یا بی‌معنی است، حدس نزن؛ ' + 'کوتاه از کاربر بخواه جمله را دوباره و واضح‌تر تکرار کند. ' + 'در مورد فعالیت‌های غیرقانونی کمک نکن، مشاوره مضر نده، و اگر سؤالی شامل ' + 'اطلاعات شخصی حساس یا موضوع پیچیده‌ای فراتر از توانایی تو بود، کاربر را ' + 'به یک انسان ارجاع بده.'; + } // ----- state ------------------------------------------------------ IOWebSocketChannel? _ws; @@ -82,10 +170,19 @@ class _AiVoiceChatDialogState extends State String _statusText = 'در حال اتصال...'; bool _isRecording = false; + bool _isStartingRecorder = false; + bool _isMicMuted = false; bool _isAiSpeaking = false; bool _isUserSpeaking = false; bool _isUserAudioLocked = false; Timer? _unlockUserMicTimer; + // Absolute wall-clock cutoff: mic bytes MUST NOT reach the server before + // this moment. Set whenever AI audio is being played (extended on each + // audio chunk). Purpose: guarantee the speaker's echo tail can never + // reach xAI's server VAD and trigger a self-answering loop, even if + // _isAiSpeaking / _isUserAudioLocked state gets confused. + DateTime? _echoCooldownUntil; + static const int _echoCooldownAfterChunkMs = 500; // ----- audio ------------------------------------------------------ final AudioRecorder _audioRecorder = AudioRecorder(); @@ -95,16 +192,44 @@ class _AiVoiceChatDialogState extends State // and never releases the AudioTrack while feeds are pending. final AudioStream _audioPlayer = getAudioStream(); StreamSubscription? _audioStreamSubscription; + Timer? _micHealthTimer; + DateTime? _lastMicChunkAt; + DateTime? _lastMicChunkSentAt; + int _micChunksReceived = 0; + int _micChunksSent = 0; + bool _isRestartingMic = false; bool _isPlayerInitialized = false; final ListQueue<_QueuedPcmChunk> _pendingPlaybackChunks = ListQueue(); + final ListQueue<_PlaybackReferenceChunk> _playbackReferences = ListQueue(); Timer? _playbackPumpTimer; Timer? _playbackCompletionTimer; DateTime? _estimatedPlaybackEnd; bool _serverAudioDone = false; - final ListQueue _bargeInPreRoll = ListQueue(); + bool _playbackSegmentSealed = false; + bool _playbackSegmentStarted = false; + int _pendingPlaybackSampleCount = 0; + // output_audio.done can mark only one spoken segment around a built-in tool + // call. Keep it separate from response.done, otherwise a web-search pause + // clears the active response id and the follow-up audio deltas get rejected. + bool _responseLifecycleDone = false; + int _audioDeltasReceived = 0; + int _audioSamplesQueued = 0; int _bargeInLoudChunks = 0; int _bargeInLoudSamples = 0; + bool _bargeInProbeActive = false; + DateTime? _bargeInProbeEvaluateAfter; + int _bargeInProbeVoiceChunks = 0; + double _bargeInProbePeakRms = 0; + Timer? _bargeInProbeTimer; + // Feedback loop against echo-triggered false probes: remember what energy + // triggered the last probe and how many consecutive probes were rejected, + // so rejections can lift the echo baseline and back off exponentially. + double _lastBargeInTriggerRms = 0; + int _bargeInProbeRejections = 0; double _echoRmsBaseline = 0; + int _rmsLogSkip = 0; + int _echoLogSkip = 0; + int _tailBargeInLogSkip = 0; DateTime? _allowBargeInAfter; // On an actual user interrupt, bump generation and drain all pending audio. int _playerGeneration = 0; @@ -113,19 +238,139 @@ class _AiVoiceChatDialogState extends State /// نیامده، chunkها را داخل این لیست نگه می‌داریم و یک‌جا flush می‌کنیم. /// این یک نکته مهم UX است: بدون آن، اولین ۲۰۰-۷۰۰ms صحبت کاربر گم می‌شود. final List _micBufferBeforeReady = []; + int _micBufferedBytes = 0; + static const int _maxPreSessionAudioBytes = _sampleRate * 2 * 10; // ----- animation -------------------------------------------------- late AnimationController _orbController; late AnimationController _rippleController; late AnimationController _waveController; final List _audioWaveHeights = List.generate(40, (_) => 0.1); + final ScrollController _transcriptController = ScrollController(); + final List<_VoiceMessage> _messages = []; + String _liveAiTranscript = ''; + double _aiAudioLevel = 0; + double _aiVoiceTexture = 0; + double _userAudioLevel = 0; + double _userVoiceTexture = 0; + double _orbEnvelope = 0; + double _orbVelocity = 0; + double _orbTransient = 0; + double _orbTexture = 0; + double _orbMotionPhase = 0; + double _previousOrbDrive = 0; + ui.FragmentShader? _orbShader; @override void initState() { super.initState(); + _audioEventsChannel.setMethodCallHandler(_handlePlatformAudioEvent); _initAnimations(); - _initAudio(); - _connectXai(); + _loadOrbShader(); + _initializeVoiceChat(); + // Self-healing watchdog: the mic gating stack (_assistantOwnsAudioTurn + // + echo cooldown) has several state producers; a single missed + // lifecycle event (dropped response.done, cancelled tool call, stray + // response id) leaves the user permanently muted. Every 2s, if we + // claim the assistant owns the turn but no assistant audio has arrived + // or played for 10s, force-release the turn. + _turnWatchdog = Timer.periodic( + const Duration(seconds: 2), + (_) => _turnWatchdogTick(), + ); + } + + Timer? _turnWatchdog; + DateTime? _lastAssistantAudioAt; + + void _turnWatchdogTick() { + if (!mounted || _isStopping) return; + if (!_assistantOwnsAudioTurn) return; + // ⚠️ Do NOT call _audioPlayer.resume() here "just in case" — on this + // device/package combination repeated resume() killed playback + // entirely (no audio at all, transcript only). If mid-sentence audio + // pauses come back, they need a targeted fix with stat() diagnostics, + // not a blind periodic resume. + if (_isPlayerInitialized && _playbackSegmentStarted) { + try { + final stat = _audioPlayer.stat(); + debugPrint( + '📈 player stat: full=${stat.full} exhaust=${stat.exhaust} ' + 'queued=${_pendingPlaybackChunks.length} ' + 'endsIn=${_estimatedPlaybackEnd?.difference(DateTime.now()).inMilliseconds}ms', + ); + } catch (_) {} + } + var last = _lastAssistantAudioAt; + if (last == null) { + // No audio yet in this turn — START the idle clock now instead of + // treating "null" as infinitely idle. The old sentinel (1<<30 ms) + // made the watchdog fire in the gap between response.created and the + // first audio delta, nulling _currentResponseId — after which every + // arriving audio delta was rejected and the user got text-only + // responses with no sound. + last = DateTime.now(); + _lastAssistantAudioAt = last; + return; + } + final idleMs = DateTime.now().difference(last).inMilliseconds; + final queueEmpty = _pendingPlaybackChunks.isEmpty; + final playbackOver = _estimatedPlaybackEnd == null || + _estimatedPlaybackEnd!.isBefore(DateTime.now()); + if (idleMs > 10000 && queueEmpty && playbackOver) { + debugPrint( + '🛟 turn watchdog: releasing stale assistant turn (idle=${idleMs}ms ' + 'responseId=$_currentResponseId)'); + _responseLifecycleDone = true; + _finishPlaybackTurn(_playerGeneration); + _currentResponseId = null; + _echoCooldownUntil = null; + } + } + + Future _handlePlatformAudioEvent(MethodCall call) async { + if (call.method != 'volumeChanged' || !_isAiSpeaking) return; + + // Raising/lowering the hardware volume changes speaker-to-mic gain + // instantly, while Android's AEC needs a short window to converge again. + // During that window learn the new echo floor instead of interpreting the + // louder copy of Houshan's voice as a user interruption. + final settlesAt = DateTime.now().add( + const Duration(milliseconds: _volumeGainSettlingMilliseconds), + ); + final currentlyAllowedAfter = _allowBargeInAfter; + if (currentlyAllowedAfter == null || + settlesAt.isAfter(currentlyAllowedAfter)) { + _allowBargeInAfter = settlesAt; + } + _bargeInLoudChunks = 0; + _bargeInLoudSamples = 0; + debugPrint('🔉 volume changed — re-learning speaker echo for ' + '${_volumeGainSettlingMilliseconds}ms'); + } + + Future _loadOrbShader() async { + try { + final program = + await ui.FragmentProgram.fromAsset('lib/shaders/ai_orb_3d.frag'); + if (!mounted) return; + setState(() => _orbShader = program.fragmentShader()); + } catch (error) { + debugPrint('❌ AI orb shader load failed: $error'); + } + } + + Future _initializeVoiceChat() async { + await _initAudio(); + if (!mounted || _isStopping) return; + + // Capture immediately, before token fetch + WebSocket setup. Chunks are + // retained in _micBufferBeforeReady and flushed only after session.updated, + // so speech during the first connection seconds is no longer lost. + await _startMicCapture(); + if (!mounted || _isStopping) return; + + await _connectXai(); } void _initAnimations() { @@ -147,13 +392,46 @@ class _AiVoiceChatDialogState extends State if (_isRecording || _isAiSpeaking) { if (mounted) { setState(() { - final random = math.Random(); + _aiAudioLevel *= 0.9; + _aiVoiceTexture *= 0.91; + _userAudioLevel *= 0.82; + _userVoiceTexture *= 0.9; + final level = _isAiSpeaking ? _aiAudioLevel : _userAudioLevel; + final userDrive = (!_isMicMuted && !_assistantOwnsAudioTurn) + ? ((_userAudioLevel - 0.13) / 0.67).clamp(0.0, 1.0).toDouble() + : 0.0; + final orbDrive = _isAiSpeaking ? _aiAudioLevel : userDrive; + final textureDrive = _isAiSpeaking + ? _aiVoiceTexture + : _userVoiceTexture * userDrive; + final envelopeDelta = orbDrive - _orbEnvelope; + final envelopeResponse = envelopeDelta > 0 ? 0.38 : 0.1; + final previousEnvelope = _orbEnvelope; + _orbEnvelope = (_orbEnvelope + envelopeDelta * envelopeResponse) + .clamp(0.0, 1.0) + .toDouble(); + final onset = math.max(0.0, orbDrive - _previousOrbDrive); + _orbTransient = math + .max(_orbTransient * 0.76, onset * 1.75) + .clamp(0.0, 1.0) + .toDouble(); + _orbVelocity = (_orbVelocity * 0.72) + + ((_orbEnvelope - previousEnvelope) * 0.7); + _orbTexture += (textureDrive - _orbTexture) * + (textureDrive > _orbTexture ? 0.28 : 0.12); + _orbMotionPhase = (_orbMotionPhase + + 0.018 + + _orbEnvelope * 0.038 + + _orbTexture * 0.012) % + (math.pi * 2); + _previousOrbDrive = orbDrive; for (int i = 0; i < _audioWaveHeights.length; i++) { - double target = 0.1 + (random.nextDouble() * 0.4); - if (_isAiSpeaking) target *= 1.5; - if (_isUserSpeaking) target *= 2.0; + final phase = i / _audioWaveHeights.length * math.pi * 5; + final organic = + 0.55 + 0.45 * math.sin(phase + _orbController.value * 8); + final target = 0.06 + level * (0.34 + organic * 0.66); _audioWaveHeights[i] = _audioWaveHeights[i] + - (target - _audioWaveHeights[i]) * 0.2; + (target - _audioWaveHeights[i]) * 0.28; } }); } @@ -166,6 +444,36 @@ class _AiVoiceChatDialogState extends State ..repeat(); } + void _scrollTranscriptToEnd() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !_transcriptController.hasClients) return; + _transcriptController.animateTo( + _transcriptController.position.maxScrollExtent, + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ); + }); + } + + void _appendAiTranscript(String delta) { + if (!mounted || delta.isEmpty) return; + setState(() => _liveAiTranscript += delta); + _scrollTranscriptToEnd(); + } + + void _commitAiTranscript([String? finalText]) { + if (!mounted) return; + final text = (finalText?.trim().isNotEmpty ?? false) + ? finalText!.trim() + : _liveAiTranscript.trim(); + if (text.isEmpty) return; + setState(() { + _messages.add(_VoiceMessage(text)); + _liveAiTranscript = ''; + }); + _scrollTranscriptToEnd(); + } + Future _initAudio() async { try { final session = await AudioSession.instance; @@ -178,7 +486,10 @@ class _AiVoiceChatDialogState extends State androidAudioAttributes: const AndroidAudioAttributes( contentType: AndroidAudioContentType.speech, flags: AndroidAudioFlags.none, - usage: AndroidAudioUsage.media, + // Live conversational assistants should declare USAGE_ASSISTANT. + // This keeps semantic routing/focus distinct from generic media + // without switching Android into the low-volume CALL stream. + usage: AndroidAudioUsage.assistant, ), androidAudioFocusGainType: AndroidAudioFocusGainType.gain, )); @@ -189,7 +500,7 @@ class _AiVoiceChatDialogState extends State channels: 1, sampleRate: _sampleRate, bufferMilliSec: _playerBufferMillis, - waitingBufferMilliSec: 100, + waitingBufferMilliSec: _nativeRecoveryBufferMilliseconds, ); if (initResult != 0) { throw StateError('mp_audio_stream init failed: $initResult'); @@ -306,19 +617,37 @@ class _AiVoiceChatDialogState extends State 'instructions': _instructions, 'turn_detection': { 'type': 'server_vad', - 'threshold': 0.85, - 'prefix_padding_ms': 333, - 'silence_duration_ms': 500, + // Keep this below the previous 0.92: that value rejected normal + // speech on many phone microphones. Local echo protection still + // prevents the assistant audio from being submitted as a turn. + 'threshold': 0.5, + // Retain enough audio before VAD activation to recover the first + // word when a user begins softly. + 'prefix_padding_ms': 700, + 'silence_duration_ms': 700, }, 'resumption': {'enabled': true}, 'tools': [ - {'type': 'web_search'}, + { + 'type': 'web_search', + 'location': { + 'country': 'IR', + 'city': 'Tehran', + 'timezone': 'Asia/Tehran', + }, + }, {'type': 'x_search'}, ], - 'input_audio_transcription': {'model': 'grok-2-audio'}, 'audio': { 'input': { 'format': {'type': 'audio/pcm', 'rate': _sampleRate}, + 'transcription': { + 'model': 'grok-transcribe', + // Current xAI Voice Agent API expects a BCP-47 hint here. + // The old top-level input_audio_transcription/language fields + // were ignored, leaving short Persian phrases to auto-detect. + 'language_hint': 'fa-IR', + }, }, 'output': { 'format': {'type': 'audio/pcm', 'rate': _sampleRate}, @@ -419,20 +748,30 @@ class _AiVoiceChatDialogState extends State } } - void _unlockUserMicWithDelay() { + /// Immediate unlock — used at real end-of-turn (response.done, + /// interrupt, dispose) so a cancelled unlock timer can never leave the + /// mic permanently frozen. Silent no-op when already unlocked. + void _unlockUserMicNow() { _unlockUserMicTimer?.cancel(); - _unlockUserMicTimer = Timer(const Duration(milliseconds: 250), () { - if (!mounted) return; + if (!_isUserAudioLocked) return; + if (mounted) { setState(() => _isUserAudioLocked = false); - }); + } else { + _isUserAudioLocked = false; + } } void _hardResetPlayback() { _playbackPumpTimer?.cancel(); _playbackCompletionTimer?.cancel(); _pendingPlaybackChunks.clear(); + _playbackReferences.clear(); _estimatedPlaybackEnd = null; _serverAudioDone = false; + _playbackSegmentSealed = false; + _playbackSegmentStarted = false; + _pendingPlaybackSampleCount = 0; + _responseLifecycleDone = false; if (!_isPlayerInitialized) return; try { _audioPlayer.uninit(); @@ -440,7 +779,7 @@ class _AiVoiceChatDialogState extends State channels: 1, sampleRate: _sampleRate, bufferMilliSec: _playerBufferMillis, - waitingBufferMilliSec: 100, + waitingBufferMilliSec: _nativeRecoveryBufferMilliseconds, ); if (initResult != 0) { _isPlayerInitialized = false; @@ -476,23 +815,53 @@ class _AiVoiceChatDialogState extends State _isSessionReady = true; _reconnectAttempt = 0; // flush buffered audio - for (final chunk in _micBufferBeforeReady) { - _send({ - 'type': 'input_audio_buffer.append', - 'audio': base64Encode(chunk), - }); + if (!_isMicMuted) { + for (final chunk in _micBufferBeforeReady) { + _send({ + 'type': 'input_audio_buffer.append', + 'audio': base64Encode(chunk), + }); + } } _micBufferBeforeReady.clear(); + _micBufferedBytes = 0; + _startMicHealthWatchdog(); if (mounted) { setState(() => _statusText = 'گوش می‌دهم...'); } break; case 'input_audio_buffer.speech_started': - // کاربر شروع به صحبت کرد — پخش AI را قطع کن و response را cancel کن - debugPrint('🎤 speech_started — interrupt AI'); - if (_isAiSpeaking || _currentResponseId != null) { - _interruptAi(); + if (_isMicMuted) { + debugPrint('🔇 speech_started ignored while microphone is muted'); + _send({'type': 'input_audio_buffer.clear'}); + break; + } + if (_speakerLikelyAudible) { + // The client KNOWS the speaker may still be emitting assistant + // audio, so whatever the server VAD just heard cannot be trusted: + // mic audio is locally gated in this window, meaning this event + // was triggered by echo/in-flight residue, not the user. Purge it + // and keep the response alive — the local probe detector is the + // only trusted in-playback interrupt path. + debugPrint('🔁 speech_started while speaker audible — ' + 'treating as echo, clearing server buffer'); + _send({'type': 'input_audio_buffer.clear'}); + break; + } + // Server VAD heard the user while the speaker was definitely + // silent — this IS a trusted barge-in signal. Cut local playback + // instantly; the server handles cancelling generation on its side. + debugPrint('🎤 user speech_started'); + if (_assistantOwnsAudioTurn) { + debugPrint('✂️ user spoke during assistant turn — interrupting'); + _interruptAi( + reason: 'server_vad_speech_started', + // Do NOT clear the input buffer — it holds the very speech + // that triggered this event. + clearInputBuffer: false, + keepMicOpen: true, + ); } if (mounted) setState(() => _isUserSpeaking = true); break; @@ -501,6 +870,20 @@ class _AiVoiceChatDialogState extends State if (mounted) setState(() => _isUserSpeaking = false); break; + case 'conversation.item.input_audio_transcription.updated': + final transcript = event['transcript']?.toString() ?? ''; + if (transcript.isNotEmpty) { + debugPrint('📝 USER (live): $transcript'); + } + break; + + case 'conversation.item.input_audio_transcription.completed': + final transcript = event['transcript']?.toString() ?? ''; + if (transcript.isNotEmpty) { + debugPrint('📝 USER (final): $transcript'); + } + break; + case 'response.created': // A new server response must be appended after already-buffered audio. // Resetting the player here cuts the previous sentence (especially @@ -510,9 +893,23 @@ class _AiVoiceChatDialogState extends State event['response_id']?.toString(); _finishedResponseId = null; _serverAudioDone = false; + _playbackSegmentSealed = false; + _playbackSegmentStarted = false; + _pendingPlaybackSampleCount = 0; + _responseLifecycleDone = false; + _audioDeltasReceived = 0; + _audioSamplesQueued = 0; + // Restart the turn-watchdog idle clock for THIS response — + // without this, the stamp from the previous turn makes the + // watchdog think the new response is already 10s+ idle and it + // kills it before the first audio delta arrives. + _lastAssistantAudioAt = DateTime.now(); _playbackCompletionTimer?.cancel(); _resetBargeInDetector(); _lockUserMic(); + if (_liveAiTranscript.trim().isNotEmpty) { + _commitAiTranscript(); + } if (mounted) { setState(() { _isAiSpeaking = true; @@ -533,9 +930,24 @@ class _AiVoiceChatDialogState extends State } final delta = event['delta']?.toString(); if (delta != null && delta.isNotEmpty) { - _allowBargeInAfter ??= - DateTime.now().add(const Duration(milliseconds: 450)); + // A built-in web/X search may split one response into multiple + // spoken segments. output_audio.done ends the first segment, then + // more deltas with the same response id arrive after the tool. + _serverAudioDone = false; + _playbackSegmentSealed = false; + _finishedResponseId = null; + _playbackCompletionTimer?.cancel(); + _audioDeltasReceived++; + _lastAssistantAudioAt = DateTime.now(); + _allowBargeInAfter ??= DateTime.now().add( + const Duration(milliseconds: _bargeInArmDelayMilliseconds)); _playPcmChunk(delta, _playerGeneration); + if (_audioDeltasReceived == 1 || _audioDeltasReceived % 25 == 0) { + debugPrint( + '🔊 audio delta accepted: count=$_audioDeltasReceived ' + 'response=${deltaResponseId ?? _currentResponseId}', + ); + } if (mounted && !_isAiSpeaking) { setState(() { _isAiSpeaking = true; @@ -559,17 +971,52 @@ class _AiVoiceChatDialogState extends State } _finishedResponseId = completedResponseId; _serverAudioDone = true; + _sealPlaybackSegment(_playerGeneration); + _pumpPlaybackQueue(); _schedulePlaybackCompletion(_playerGeneration); break; case 'response.output_audio_transcript.delta': - // transcript live — می‌توان در UI نشان داد. فعلاً فقط لاگ final delta = event['delta']?.toString() ?? ''; - if (delta.isNotEmpty) debugPrint('💬 AI: $delta'); + if (delta.isNotEmpty) { + debugPrint('💬 AI: $delta'); + _appendAiTranscript(delta); + } + break; + + case 'response.output_audio_transcript.done': + final transcript = event['transcript']?.toString() ?? ''; + _commitAiTranscript(transcript); break; case 'response.output_item.added': + final item = event['item']; + final itemJson = item == null ? '' : jsonEncode(item).toLowerCase(); + if (itemJson.contains('web_search') || + itemJson.contains('x_search')) { + debugPrint('🔎 server-side search started: $itemJson'); + if (mounted) { + setState(() => _statusText = 'در حال جست‌وجوی وب...'); + } + } else if (kDebugMode) { + debugPrint('🧪 output item added: ${jsonEncode(event)}'); + } + break; + case 'response.output_item.done': + final item = event['item']; + final itemJson = item == null ? '' : jsonEncode(item).toLowerCase(); + if (itemJson.contains('web_search') || + itemJson.contains('x_search')) { + debugPrint('✅ server-side search completed'); + if (mounted) { + setState(() => _statusText = 'در حال آماده‌سازی پاسخ...'); + } + } else if (kDebugMode) { + debugPrint('🧪 output item done: ${jsonEncode(event)}'); + } + break; + case 'response.content_part.done': case 'response.transcript.done': case 'response.function_call': @@ -590,6 +1037,10 @@ class _AiVoiceChatDialogState extends State } _finishedResponseId ??= doneResponseId ?? _currentResponseId; _serverAudioDone = true; + _sealPlaybackSegment(_playerGeneration); + _pumpPlaybackQueue(); + _responseLifecycleDone = true; + _commitAiTranscript(); _schedulePlaybackCompletion(_playerGeneration); break; @@ -608,15 +1059,19 @@ class _AiVoiceChatDialogState extends State final errorType = errorDetails['type']?.toString() ?? event['error_type']?.toString() ?? 'unknown_type'; - debugPrint('⚠️ xAI error [$errorType/$code]: $msg'); - if (kDebugMode) { - debugPrint('⚠️ xAI error payload=${jsonEncode(event)}'); - } final normalizedMessage = msg.toLowerCase(); final isBenignCancelRace = normalizedMessage.contains('no active response') || normalizedMessage.contains('not active'); - if (mounted && !isBenignCancelRace) { + if (isBenignCancelRace) { + debugPrint('ℹ️ response.cancel race ignored: $msg'); + break; + } + debugPrint('⚠️ xAI error [$errorType/$code]: $msg'); + if (kDebugMode) { + debugPrint('⚠️ xAI error payload=${jsonEncode(event)}'); + } + if (mounted) { setState(() => _statusText = 'خطا: $msg'); } break; @@ -637,6 +1092,27 @@ class _AiVoiceChatDialogState extends State String? _currentResponseId; String? _finishedResponseId; + /// True while the speaker is (or may still be) emitting assistant audio. + /// Derived from the playback-end estimate plus a fixed hardware/echo tail, + /// AND from the echo cooldown, which covers the physical tail after + /// playback drained or was cut. Mic bytes must never reach the server + /// while this is true — the server VAD transcribes even near-silent echo + /// as user speech and the assistant ends up answering itself. + bool get _speakerLikelyAudible { + final cooldown = _echoCooldownUntil; + if (cooldown != null && DateTime.now().isBefore(cooldown)) return true; + final end = _estimatedPlaybackEnd; + if (end == null) return _pendingPlaybackChunks.isNotEmpty; + return DateTime.now().isBefore(end.add(const Duration(milliseconds: 400))); + } + + bool get _assistantOwnsAudioTurn { + if (_isAiSpeaking || _currentResponseId != null) return true; + if (_pendingPlaybackChunks.isNotEmpty) return true; + final playbackEnd = _estimatedPlaybackEnd; + return playbackEnd != null && playbackEnd.isAfter(DateTime.now()); + } + // Single-byte remainder carried across chunks if a chunk has odd length // (rare but happens on some network split points). Without carrying it, // the next chunk's first byte pairs with the wrong high byte and every @@ -670,10 +1146,31 @@ class _AiVoiceChatDialogState extends State final view = ByteData.sublistView(bytes, 0, usableLen); final sampleCount = usableLen ~/ 2; final samples = Float32List(sampleCount); + var sumSquares = 0.0; + var sumDifferenceSquares = 0.0; + var previousSample = 0.0; for (var i = 0; i < sampleCount; i++) { - samples[i] = view.getInt16(i * 2, Endian.little) / 32768.0; + final normalized = + (view.getInt16(i * 2, Endian.little) / 32768.0) * _playbackGain; + final sample = normalized.clamp(-1.0, 1.0).toDouble(); + samples[i] = sample; + sumSquares += sample * sample; + if (i > 0) { + final difference = sample - previousSample; + sumDifferenceSquares += difference * difference; + } + previousSample = sample; } + final rms = math.sqrt(sumSquares / sampleCount); + final differenceRms = + math.sqrt(sumDifferenceSquares / math.max(1, sampleCount - 1)); + _aiAudioLevel = math.max(_aiAudioLevel * 0.62, (rms * 4.2).clamp(0, 1)); + _aiVoiceTexture = math.max( + _aiVoiceTexture * 0.66, + (differenceRms * 3.4).clamp(0, 1), + ); if (!_isPlayerInitialized || generation != _playerGeneration) return; + _audioSamplesQueued += sampleCount; _enqueuePlaybackSamples(samples, generation); } catch (e) { debugPrint('❌ playPcmChunk error: $e'); @@ -687,14 +1184,59 @@ class _AiVoiceChatDialogState extends State final tail = _estimatedPlaybackEnd; final startsAt = tail != null && tail.isAfter(now) ? tail - : now.add(const Duration(milliseconds: 100)); + : now.add( + const Duration(milliseconds: _initialJitterBufferMilliseconds), + ); final durationMicros = samples.length * Duration.microsecondsPerSecond ~/ _sampleRate; _estimatedPlaybackEnd = startsAt.add(Duration(microseconds: durationMicros)); + _playbackReferences.add( + _PlaybackReferenceChunk( + samples: samples, + startsAt: startsAt, + endsAt: _estimatedPlaybackEnd!, + ), + ); + final staleBefore = now.subtract(const Duration(seconds: 1)); + while (_playbackReferences.isNotEmpty && + _playbackReferences.first.endsAt.isBefore(staleBefore)) { + _playbackReferences.removeFirst(); + } _pendingPlaybackChunks.add(_QueuedPcmChunk(samples, generation)); + _pendingPlaybackSampleCount += samples.length; _pumpPlaybackQueue(); + + // Push echo-cooldown further into the future on every chunk we hand + // to the player — anchored to when this chunk will actually finish + // playing out of the speaker, plus a safety margin for the echo tail. + final projected = _estimatedPlaybackEnd! + .add(const Duration(milliseconds: _echoCooldownAfterChunkMs)); + if (_echoCooldownUntil == null || projected.isAfter(_echoCooldownUntil!)) { + _echoCooldownUntil = projected; + } + } + + /// A tiny silent marker guarantees the speech ahead of it drains even when + /// the final server delta is shorter than the native recovery threshold. + void _sealPlaybackSegment(int generation) { + if (_playbackSegmentSealed || + !_isPlayerInitialized || + generation != _playerGeneration) { + return; + } + _playbackSegmentSealed = true; + const paddingSamples = _sampleRate * + _playbackDrainPaddingMilliseconds ~/ + Duration.millisecondsPerSecond; + _enqueuePlaybackSamples(Float32List(paddingSamples), generation); + final stat = _audioPlayer.stat(); + debugPrint( + '🧩 playback segment sealed ' + '(padding=${_playbackDrainPaddingMilliseconds}ms ' + 'exhaust=${stat.exhaust} full=${stat.full})', + ); } /// mp_audio_stream drops a whole chunk when its native ring buffer is full. @@ -704,9 +1246,25 @@ class _AiVoiceChatDialogState extends State _playbackPumpTimer?.cancel(); _playbackPumpTimer = null; + if (!_playbackSegmentStarted) { + const initialSamples = _sampleRate * + _initialJitterBufferMilliseconds ~/ + Duration.millisecondsPerSecond; + if (!_serverAudioDone && _pendingPlaybackSampleCount < initialSamples) { + return; + } + _playbackSegmentStarted = true; + debugPrint( + '▶️ playback started after jitter buffer ' + '(samples=$_pendingPlaybackSampleCount)', + ); + } + while (_pendingPlaybackChunks.isNotEmpty) { final chunk = _pendingPlaybackChunks.first; if (chunk.generation != _playerGeneration) { + _pendingPlaybackSampleCount = + math.max(0, _pendingPlaybackSampleCount - chunk.samples.length); _pendingPlaybackChunks.removeFirst(); continue; } @@ -714,12 +1272,20 @@ class _AiVoiceChatDialogState extends State final result = _audioPlayer.push(chunk.samples); if (result != 0) { + final stat = _audioPlayer.stat(); + debugPrint( + '⏳ player buffer full; retrying ' + '(queued=${_pendingPlaybackChunks.length} ' + 'full=${stat.full} exhaust=${stat.exhaust})', + ); _playbackPumpTimer = Timer( const Duration(milliseconds: 25), _pumpPlaybackQueue, ); return; } + _pendingPlaybackSampleCount = + math.max(0, _pendingPlaybackSampleCount - chunk.samples.length); _pendingPlaybackChunks.removeFirst(); } @@ -759,11 +1325,36 @@ class _AiVoiceChatDialogState extends State void _finishPlaybackTurn(int generation) { if (generation != _playerGeneration) return; _playbackCompletionTimer?.cancel(); - _currentResponseId = null; + // Do not discard the response id merely because one audio segment ended. + // Built-in web_search/x_search can pause for seconds and then continue + // streaming audio inside the same response lifecycle. + if (_responseLifecycleDone) { + _currentResponseId = null; + } _pcmCarryByte = null; _estimatedPlaybackEnd = null; _serverAudioDone = false; + _playbackSegmentSealed = false; + _playbackSegmentStarted = false; + _pendingPlaybackSampleCount = 0; + if (_responseLifecycleDone) { + debugPrint( + '🏁 playback complete: deltas=$_audioDeltasReceived ' + 'samples=$_audioSamplesQueued', + ); + _responseLifecycleDone = false; + } else { + debugPrint('⏸️ audio segment drained; keeping response active for tool'); + } + _playbackReferences.clear(); _resetBargeInDetector(); + // The native/output device can still emit samples after Dart's estimated + // queue end. Keep that physical tail entirely out of server VAD. Local + // barge-in remains available while actual assistant audio is playing. + _echoCooldownUntil = DateTime.now().add( + const Duration(milliseconds: _naturalPlaybackEchoTailMilliseconds), + ); + _unlockUserMicNow(); if (mounted) { setState(() { _isAiSpeaking = false; @@ -771,15 +1362,50 @@ class _AiVoiceChatDialogState extends State _isSessionReady ? 'گوش می‌دهم...' : 'در حال اتصال مجدد...'; }); } - _unlockUserMicWithDelay(); } - void _interruptAi({bool clearInputBuffer = false}) { + void _interruptAi({ + required String reason, + bool clearInputBuffer = false, + // When true, skip the 300ms post-interrupt mini-cooldown. Callers that + // are ABOUT to send freshly-captured user audio (local barge-in with a + // pre-roll queue) need the mic unblocked immediately, otherwise the + // pre-roll gets swallowed by cooldown and xAI never sees the start of + // the user's sentence. + bool keepMicOpen = false, + }) { + final isAutomaticVoiceInterrupt = reason == 'confirmed_local_voice'; + final isBufferedPlaybackTail = _serverAudioDone || + _responseLifecycleDone || + _currentResponseId == null; + if (isAutomaticVoiceInterrupt && isBufferedPlaybackTail) { + _resetBargeInDetector(); + debugPrint( + '🛡️ stale automatic interrupt blocked: ' + 'response=${_currentResponseId ?? 'none'} ' + 'serverAudioDone=$_serverAudioDone ' + 'responseDone=$_responseLifecycleDone', + ); + return; + } + _commitAiTranscript(); // response.done may already have arrived while local audio is still queued. // In that state only stop local playback; response.cancel would be invalid. - final hadActiveServerResponse = - _currentResponseId != null && !_serverAudioDone; + final hadActiveServerResponse = _currentResponseId != null && + !_serverAudioDone && + !_responseLifecycleDone; + debugPrint( + '🛑 AI interrupt executing: reason=$reason ' + 'response=${_currentResponseId ?? 'none'} ' + 'serverActive=$hadActiveServerResponse ' + 'queued=${_pendingPlaybackChunks.length}', + ); _unlockUserMicTimer?.cancel(); + // Clear echo cooldown immediately — the AI is being cut off NOW, so + // the estimated playback end is stale. Without this the mic bytes + // stay blocked for the full remaining projected duration and the + // user's next words never reach xAI. + _echoCooldownUntil = null; if (mounted) { setState(() => _isUserAudioLocked = false); } @@ -793,6 +1419,7 @@ class _AiVoiceChatDialogState extends State // cancelled response will be filtered out by response.output_audio.delta. _currentResponseId = null; _finishedResponseId = null; + _responseLifecycleDone = false; _resetBargeInDetector(); _hardResetPlayback(); if (clearInputBuffer) { @@ -809,14 +1436,76 @@ class _AiVoiceChatDialogState extends State _isSessionReady ? 'گوش می‌دهم...' : 'در حال اتصال مجدد...'; }); } + // Short mini-cooldown so the last ~300ms of AI audio already in the + // hardware buffer doesn't leak back as user speech. Much shorter than + // the natural end-of-turn cooldown because the AI is silent from here. + // Skipped when a caller is about to push fresh mic audio itself. + if (!keepMicOpen) { + _echoCooldownUntil = + DateTime.now().add(const Duration(milliseconds: 300)); + } } + /// Fired when the user taps the sphere. One reliable action regardless + /// of internal state: kill any AI playback + server response, clear the + /// echo cooldown so the mic goes live now, and give a brief visual cue. + /// This is the guaranteed "I want to talk" path. + void _handleUserTakeover() { + debugPrint('👆 user takeover — interrupt + open mic'); + _interruptAi( + reason: 'user_tap', + clearInputBuffer: true, + keepMicOpen: true, + ); + _echoCooldownUntil = null; + if (mounted) { + setState(() { + _isMicMuted = false; + _isAiSpeaking = false; + _statusText = 'گوش می‌دهم...'; + }); + } + } + + void _toggleMicrophone() { + final shouldMute = !_isMicMuted; + if (shouldMute) { + _send({'type': 'input_audio_buffer.clear'}); + _micBufferBeforeReady.clear(); + _micBufferedBytes = 0; + _isUserSpeaking = false; + _userAudioLevel = 0; + } else { + _echoCooldownUntil = null; + } + setState(() { + _isMicMuted = shouldMute; + _statusText = shouldMute ? 'میکروفن خاموش است' : 'گوش می‌دهم...'; + }); + } + + Future _closeVoiceChat() async { + await _stopAll(); + if (mounted) Navigator.of(context).pop(); + } + + int _bargeInQuietRun = 0; + void _resetBargeInDetector() { - _bargeInPreRoll.clear(); + _bargeInProbeTimer?.cancel(); + _bargeInProbeTimer = null; + _bargeInProbeActive = false; + _bargeInProbeEvaluateAfter = null; + _bargeInProbeVoiceChunks = 0; + _bargeInProbePeakRms = 0; _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; + _bargeInQuietRun = 0; _echoRmsBaseline = 0; _allowBargeInAfter = null; + _tailBargeInLogSkip = 0; + _lastBargeInTriggerRms = 0; + _bargeInProbeRejections = 0; } double _pcmRms(Uint8List chunk) { @@ -832,30 +1521,313 @@ class _AiVoiceChatDialogState extends State return math.sqrt(sumSquares / sampleCount); } - void _handlePossibleBargeIn(Uint8List chunk) { - _bargeInPreRoll.add(Uint8List.fromList(chunk)); - while (_bargeInPreRoll.length > 4) { - _bargeInPreRoll.removeFirst(); + double _pcmTexture(Uint8List chunk) { + final usableLength = chunk.length - (chunk.length & 1); + if (usableLength < 4) return 0; + final data = ByteData.sublistView(chunk, 0, usableLength); + final sampleCount = usableLength ~/ 2; + var previous = data.getInt16(0, Endian.little) / 32768.0; + var sumDifferenceSquares = 0.0; + for (var i = 1; i < sampleCount; i++) { + final current = data.getInt16(i * 2, Endian.little) / 32768.0; + final difference = current - previous; + sumDifferenceSquares += difference * difference; + previous = current; } + return math.sqrt(sumDifferenceSquares / (sampleCount - 1)); + } - final rms = _pcmRms(chunk); - final allowedAfter = _allowBargeInAfter; - if (allowedAfter == null || DateTime.now().isBefore(allowedAfter)) { - _echoRmsBaseline = - _echoRmsBaseline == 0 ? rms : (_echoRmsBaseline * 0.8) + (rms * 0.2); + Future _beginBargeInProbe() async { + if (_bargeInProbeActive || !_assistantOwnsAudioTurn || _isStopping) return; + _bargeInProbeActive = true; + _bargeInProbeVoiceChunks = 0; + _bargeInProbePeakRms = 0; + _bargeInProbeEvaluateAfter = DateTime.now().add( + const Duration(milliseconds: _bargeInProbeEchoTailMilliseconds), + ); + debugPrint('🔇 barge-in probe started — ducking speaker output'); + try { + await _audioEventsChannel.invokeMethod('beginOutputProbe'); + } catch (error) { + debugPrint('⚠️ barge-in probe unavailable: $error'); + _bargeInProbeActive = false; + _bargeInProbeEvaluateAfter = null; + _bargeInLoudChunks = 0; + _bargeInLoudSamples = 0; + return; + } + _bargeInProbeTimer?.cancel(); + _bargeInProbeTimer = Timer( + const Duration(milliseconds: _bargeInProbeDurationMilliseconds), + () => unawaited(_finishBargeInProbe()), + ); + } + + void _handleBargeInProbeChunk(double rms) { + final evaluateAfter = _bargeInProbeEvaluateAfter; + if (evaluateAfter == null || DateTime.now().isBefore(evaluateAfter)) return; + _bargeInProbePeakRms = math.max(_bargeInProbePeakRms, rms); + if (rms >= _bargeInProbeVoiceFloor) { + _bargeInProbeVoiceChunks++; + } + } + + Future _finishBargeInProbe() async { + if (!_bargeInProbeActive) return; + _bargeInProbeTimer?.cancel(); + _bargeInProbeTimer = null; + final confirmed = + _bargeInProbeVoiceChunks >= _bargeInProbeRequiredVoiceChunks; + final voiceChunks = _bargeInProbeVoiceChunks; + final peakRms = _bargeInProbePeakRms; + try { + await _audioEventsChannel.invokeMethod('endOutputProbe'); + } catch (error) { + debugPrint('⚠️ speaker volume restore failed: $error'); + } + _bargeInProbeActive = false; + _bargeInProbeEvaluateAfter = null; + _bargeInProbeVoiceChunks = 0; + _bargeInProbePeakRms = 0; + + if (!confirmed || !_assistantOwnsAudioTurn) { + _bargeInProbeRejections++; + // The energy that triggered this probe was just proven to be echo. + // Lift the learned echo baseline above it so the same echo cannot + // immediately retrigger a probe, and back off exponentially while + // rejections keep coming — every false probe is a ~460ms speaker + // mute the user hears as skipped words. + _echoRmsBaseline = math.max(_echoRmsBaseline, _lastBargeInTriggerRms); + final suppressionMs = + math.min(300 * (1 << _bargeInProbeRejections), 2400); + debugPrint( + '🔁 barge-in probe rejected as speaker echo ' + '(voiceChunks=$voiceChunks peak=${peakRms.toStringAsFixed(3)} ' + 'trigger=${_lastBargeInTriggerRms.toStringAsFixed(3)} ' + 'baseline→${_echoRmsBaseline.toStringAsFixed(3)} ' + 'suppress=${suppressionMs}ms)', + ); + _bargeInLoudChunks = 0; + _bargeInLoudSamples = 0; + _bargeInQuietRun = 0; + _allowBargeInAfter = DateTime.now().add( + Duration(milliseconds: suppressionMs), + ); return; } - final dynamicThreshold = - math.max(_bargeInRmsThreshold, _echoRmsBaseline * 2.2); - if (rms >= dynamicThreshold) { - _bargeInLoudChunks++; - _bargeInLoudSamples += chunk.length ~/ 2; - } else { - _echoRmsBaseline = - _echoRmsBaseline == 0 ? rms : (_echoRmsBaseline * 0.9) + (rms * 0.1); + _bargeInProbeRejections = 0; + debugPrint( + '🎤 barge-in probe confirmed user voice ' + '(voiceChunks=$voiceChunks peak=${peakRms.toStringAsFixed(3)})', + ); + _interruptAi( + reason: 'confirmed_local_voice', + clearInputBuffer: true, + keepMicOpen: true, + ); + _echoCooldownUntil = DateTime.now().add( + const Duration(milliseconds: _postInterruptEchoTailMilliseconds), + ); + } + + /// Compares microphone PCM with the exact PCM currently scheduled on the + /// speaker. Android AEC changes volume and may invert polarity, so Pearson + /// correlation is checked across a range of realistic speaker-to-mic delays. + /// A high score means the mic heard Houshan, not a new user utterance. + double _playbackEchoCorrelation(Uint8List chunk) { + if (_playbackReferences.isEmpty) return 0; + final usableLength = chunk.length - (chunk.length & 1); + if (usableLength < 160) return 0; + + final micData = ByteData.sublistView(chunk, 0, usableLength); + final micSampleCount = usableLength ~/ 2; + final micDurationUs = + micSampleCount * Duration.microsecondsPerSecond ~/ _sampleRate; + final micStartUs = DateTime.now().microsecondsSinceEpoch - micDurationUs; + final references = _playbackReferences.toList(growable: false); + var bestCorrelation = 0.0; + + // Search 0–700 ms in 5 ms steps. Phone speakers, Bluetooth routes and OEM + // AECs expose very different latency; the old 20 ms grid could miss the + // same waveform after a route/gain adjustment. Downsampling keeps this + // bounded even with the finer delay grid. + for (var delayMs = 0; delayMs <= 700; delayMs += 5) { + var referenceIndex = 0; + var count = 0; + var sumMic = 0.0; + var sumReference = 0.0; + var sumMicSquared = 0.0; + var sumReferenceSquared = 0.0; + var sumProduct = 0.0; + + for (var i = 0; i < micSampleCount; i += 8) { + final sourceUs = micStartUs + + (i * Duration.microsecondsPerSecond ~/ _sampleRate) - + (delayMs * Duration.microsecondsPerMillisecond); + while (referenceIndex < references.length && + sourceUs > + references[referenceIndex].endsAt.microsecondsSinceEpoch) { + referenceIndex++; + } + if (referenceIndex >= references.length) break; + + final reference = references[referenceIndex]; + final referenceStartUs = reference.startsAt.microsecondsSinceEpoch; + if (sourceUs < referenceStartUs) continue; + final sourceSample = (sourceUs - referenceStartUs) * + _sampleRate ~/ + Duration.microsecondsPerSecond; + if (sourceSample < 0 || sourceSample >= reference.samples.length) { + continue; + } + + final mic = micData.getInt16(i * 2, Endian.little) / 32768.0; + final output = reference.samples[sourceSample]; + count++; + sumMic += mic; + sumReference += output; + sumMicSquared += mic * mic; + sumReferenceSquared += output * output; + sumProduct += mic * output; + } + + if (count < 24) continue; + final covariance = sumProduct - ((sumMic * sumReference) / count); + final micVariance = sumMicSquared - ((sumMic * sumMic) / count); + final referenceVariance = + sumReferenceSquared - ((sumReference * sumReference) / count); + if (micVariance <= 0.0000001 || referenceVariance <= 0.0000001) { + continue; + } + final correlation = + (covariance / math.sqrt(micVariance * referenceVariance)).abs(); + bestCorrelation = math.max(bestCorrelation, correlation); + } + return bestCorrelation; + } + + void _handlePossibleBargeIn(Uint8List chunk) { + // The mic lock prevents audio from being submitted to the server; it must + // not disable this local detector. During assistant playback this method + // is the only path capable of noticing that the user wants to interrupt. + // + // Once output_audio.done/response.done arrives, only already-buffered + // native audio remains. mp_audio_stream underruns can shift that tail far + // away from the Dart playback references, making speaker echo correlation + // unreliable. Never auto-interrupt that tail; explicit tap-to-takeover + // remains available and still stops it immediately. + if (_serverAudioDone || + _responseLifecycleDone || + _currentResponseId == null) { _bargeInLoudChunks = 0; _bargeInLoudSamples = 0; + if (_tailBargeInLogSkip++ % 20 == 0) { + debugPrint( + '🛡️ auto barge-in disarmed for buffered playback tail ' + '(serverAudioDone=$_serverAudioDone ' + 'responseDone=$_responseLifecycleDone)', + ); + } + return; + } + final rms = _pcmRms(chunk); + if (_bargeInProbeActive) { + _handleBargeInProbeChunk(rms); + return; + } + final allowedAfter = _allowBargeInAfter; + if (allowedAfter == null || DateTime.now().isBefore(allowedAfter)) { + // Aggressive baseline learning while barge-in is disarmed — this is + // when we KNOW anything the mic picks up is echo. Track the echo PEAK + // envelope, not just the mean: speech bursts sit far above the mean + // and would otherwise cross the trigger threshold every few syllables. + // Each false trigger is a ~460ms speaker mute that eats spoken words. + final blended = + _echoRmsBaseline == 0 ? rms : (_echoRmsBaseline * 0.7) + (rms * 0.3); + _echoRmsBaseline = math.max(blended, rms * 0.9); + return; + } + + // Require real user speech above both a low floor and the learned echo + // baseline. Threshold = floor OR a modest multiple of the learned echo + // baseline. The old extra term (_aiAudioLevel * 0.16) made the bar rise + // with the assistant's own loudness, which — combined with AEC crushing + // the user's voice — made interrupting physically impossible on real + // phones. This is only a PROBE trigger: the final decision is made with + // the speaker muted, so a low bar cannot cause a false interrupt — it + // can only cause a short, self-correcting duck. + final dynamicThreshold = + math.max(_bargeInRmsThreshold, _echoRmsBaseline * 1.2); + final echoCorrelation = _playbackEchoCorrelation(chunk); + final echoLevelCeiling = math.max( + _bargeInRmsThreshold * 1.2, + _echoRmsBaseline * 1.2, + ); + final isPlaybackEcho = echoCorrelation >= _strongEchoCorrelation || + (echoCorrelation >= _possibleEchoCorrelation && + rms <= echoLevelCeiling); + if (isPlaybackEcho) { + _bargeInLoudChunks = 0; + _bargeInLoudSamples = 0; + if (rms < _echoRmsBaseline || _echoRmsBaseline == 0) { + _echoRmsBaseline = _echoRmsBaseline == 0 + ? rms + : (_echoRmsBaseline * 0.94) + (rms * 0.06); + } + if (_echoLogSkip++ % 25 == 0) { + debugPrint( + '🔁 speaker echo ignored: correlation=' + '${echoCorrelation.toStringAsFixed(2)} rms=${rms.toStringAsFixed(3)}', + ); + } + return; + } + if (rms >= dynamicThreshold) { + _bargeInQuietRun = 0; + _bargeInLoudChunks++; + _bargeInLoudSamples += chunk.length ~/ 2; + debugPrint( + '🎙️ barge-in candidate: ' + 'rms=${rms.toStringAsFixed(3)} >= ' + 'threshold=${dynamicThreshold.toStringAsFixed(3)} ' + 'chunks=$_bargeInLoudChunks ' + 'correlation=${echoCorrelation.toStringAsFixed(2)}', + ); + final loudMilliseconds = _bargeInLoudSamples * 1000 ~/ _sampleRate; + if (_bargeInLoudChunks >= _bargeInProbeTriggerChunks && + loudMilliseconds >= _bargeInProbeTriggerMilliseconds) { + _lastBargeInTriggerRms = rms; + unawaited(_beginBargeInProbe()); + return; + } + } else { + if (_rmsLogSkip++ % 20 == 0) { + debugPrint( + '🛡️ barge-in rejected: ' + 'rms=${rms.toStringAsFixed(3)} < ' + 'threshold=${dynamicThreshold.toStringAsFixed(3)} ' + 'baseline=${_echoRmsBaseline.toStringAsFixed(3)} ' + 'correlation=${echoCorrelation.toStringAsFixed(2)}', + ); + } + // Never let the baseline chase a user's gradually-rising first word. + // It may fall slowly when the speaker echo becomes quieter, but it + // must not rise after barge-in has been armed. + if (_echoRmsBaseline == 0) { + _echoRmsBaseline = rms; + } else if (rms < _echoRmsBaseline) { + _echoRmsBaseline = (_echoRmsBaseline * 0.96) + (rms * 0.04); + } + // Tolerate short dips between syllables — reset the loud counters + // only after 2 consecutive quiet chunks (~130ms). Without this, + // requiring 6 loud chunks would make natural speech (which dips + // below threshold between words) almost impossible to detect. + _bargeInQuietRun++; + if (_bargeInQuietRun >= 2) { + _bargeInLoudChunks = 0; + _bargeInLoudSamples = 0; + } return; } @@ -865,78 +1837,220 @@ class _AiVoiceChatDialogState extends State return; } - final userSpeechStart = List.from(_bargeInPreRoll); - debugPrint('🎤 local barge-in detected (rms=${rms.toStringAsFixed(3)})'); - _interruptAi(clearInputBuffer: true); - for (final bufferedChunk in userSpeechStart) { - _appendMicChunk(bufferedChunk); - } + debugPrint('🎤 local barge-in confirmed (rms=${rms.toStringAsFixed(3)} ' + 'threshold=${dynamicThreshold.toStringAsFixed(3)} ' + 'correlation=${echoCorrelation.toStringAsFixed(2)} ' + 'ms=$loudMilliseconds)'); + // Never submit pre-roll captured while the speaker was active: it contains + // the assistant's own voice and makes the model answer itself. Stop output, + // clear the server buffer, then wait only for the hardware echo tail before + // forwarding fresh microphone audio. + _interruptAi( + reason: 'confirmed_local_voice', + clearInputBuffer: true, + keepMicOpen: true, + ); + _echoCooldownUntil = DateTime.now().add( + const Duration(milliseconds: _postInterruptEchoTailMilliseconds), + ); } void _appendMicChunk(Uint8List chunk) { + if (_isMicMuted) return; + // No echo-cooldown / assistant-turn gating here anymore — full-duplex + // streaming relies on device AEC + server VAD (see mic listener). + if (_isSessionReady) { _send({ 'type': 'input_audio_buffer.append', 'audio': base64Encode(chunk), }); + _micChunksSent++; + _lastMicChunkSentAt = DateTime.now(); + if (_micChunksSent == 1 || _micChunksSent % 50 == 0) { + debugPrint( + '📤 mic audio sent: chunks=$_micChunksSent ' + 'bytes=${chunk.length} rms=${_pcmRms(chunk).toStringAsFixed(3)}', + ); + } return; } _micBufferBeforeReady.add(chunk); - // ~24kHz * 2 bytes * 10s = 480000 bytes - var total = 0; - for (final bufferedChunk in _micBufferBeforeReady) { - total += bufferedChunk.length; - } - if (total > 480000) { - _micBufferBeforeReady.removeAt(0); + _micBufferedBytes += chunk.length; + while (_micBufferedBytes > _maxPreSessionAudioBytes && + _micBufferBeforeReady.isNotEmpty) { + _micBufferedBytes -= _micBufferBeforeReady.removeAt(0).length; } } Future _startMicCapture() async { + if (_isRecording || _isStartingRecorder || _isStopping) return; + _isStartingRecorder = true; try { if (!await _audioRecorder.hasPermission()) { debugPrint('❌ mic permission denied'); if (mounted) setState(() => _statusText = 'دسترسی به میکروفون رد شد'); return; } + if (!mounted || _isStopping) return; + final stream = await _audioRecorder.startStream(const RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: _sampleRate, numChannels: 1, + // Playback is intentional and must not pause the microphone. Barge-in + // depends on receiving mic chunks while the assistant is speaking. + audioInterruption: AudioInterruptionMode.none, echoCancel: true, noiseSuppress: true, - autoGain: true, + // AGC amplifies speaker bleed during full-duplex playback and was the + // main reason local echo crossed the barge-in threshold. + autoGain: false, + streamBufferSize: 3200, + androidConfig: AndroidRecordConfig( + // VOICE_COMMUNICATION returns near-silent PCM on some OEM devices + // when the app also owns ASSISTANT audio focus. Use the universally + // available MIC source and keep explicit platform AEC/NS enabled + // above; modeNormal also preserves the assistant/media output route. + audioSource: AndroidAudioSource.mic, + audioManagerMode: AudioManagerMode.modeNormal, + speakerphone: false, + ), )); + if (!mounted || _isStopping) { + await _audioRecorder.stop(); + return; + } _isRecording = true; + _lastMicChunkAt = DateTime.now(); _audioStreamSubscription = stream.listen((chunk) { - // Keep AI playback out of the server input. We still inspect the mic - // locally with a conservative RMS gate so a real user can barge in. - if (_isAiSpeaking) { + _lastMicChunkAt = DateTime.now(); + _micChunksReceived++; + if (_isMicMuted) { + _userAudioLevel = 0; + return; + } + _userAudioLevel = + math.max(_userAudioLevel * 0.55, (_pcmRms(chunk) * 7).clamp(0, 1)); + if (!_assistantOwnsAudioTurn) { + _userVoiceTexture = math.max( + _userVoiceTexture * 0.62, + (_pcmTexture(chunk) * 4.6).clamp(0, 1), + ); + } + // Wall-clock speaker gate. Empirically the server transcribes even + // 0.006-RMS speaker echo as user speech (full-duplex experiment: + // the AI answered its own sentences verbatim), so mic audio must + // not reach the server while the speaker is audibly playing. The + // gate is a pure wall-clock window (playback estimate + tail) — + // unlike the old event-driven locks it CANNOT deadlock: worst case + // it expires by itself ≤400ms after audio ends. While gated, the + // local correlation-filtered barge-in detector is the voice + // interrupt path; tap-to-interrupt clears the window instantly. + if (_speakerLikelyAudible) { _handlePossibleBargeIn(chunk); return; } - if (_isUserAudioLocked) return; _appendMicChunk(chunk); + }, onError: (Object error, StackTrace stackTrace) { + debugPrint('❌ mic stream error: $error'); + unawaited(_restartMicCapture('stream error')); + }, onDone: () { + debugPrint('⚠️ mic stream ended unexpectedly'); + unawaited(_restartMicCapture('stream ended')); }); - debugPrint('🎙️ mic capture started @ ${_sampleRate}Hz PCM16'); + debugPrint( + '🎙️ mic capture started @ ${_sampleRate}Hz PCM16 source=mic AEC=on NS=on', + ); + _startMicHealthWatchdog(); + if (mounted && !_isSessionReady) { + setState(() => _statusText = 'میکروفن آماده — در حال اتصال...'); + } } catch (e) { debugPrint('❌ mic start error: $e'); if (mounted) setState(() => _statusText = 'خطا در میکروفون'); + } finally { + _isStartingRecorder = false; + } + } + + void _startMicHealthWatchdog() { + _micHealthTimer ??= Timer.periodic(const Duration(seconds: 2), (_) { + if (_isStopping || !_isSessionReady) return; + final now = DateTime.now(); + final lastReceived = _lastMicChunkAt; + if (lastReceived == null || + now.difference(lastReceived) > const Duration(seconds: 3)) { + debugPrint( + '⚠️ mic watchdog: no audio chunks for 3s ' + '(received=$_micChunksReceived sent=$_micChunksSent)', + ); + unawaited(_restartMicCapture('no chunks')); + return; + } + + final cooldown = _echoCooldownUntil; + final cooldownActive = cooldown != null && now.isBefore(cooldown); + if (!_assistantOwnsAudioTurn && + !cooldownActive && + (_lastMicChunkSentAt == null || + now.difference(_lastMicChunkSentAt!) > + const Duration(seconds: 3))) { + // Expired timestamps are harmless, but clear them to keep state and + // diagnostics truthful. Never clear an active physical echo tail. + _echoCooldownUntil = null; + } + }); + } + + Future _restartMicCapture(String reason) async { + if (_isStopping || _isRestartingMic) return; + _isRestartingMic = true; + debugPrint('🔄 restarting microphone: $reason'); + try { + final oldSubscription = _audioStreamSubscription; + _audioStreamSubscription = null; + await oldSubscription?.cancel(); + try { + await _audioRecorder.stop(); + } catch (_) {} + _isRecording = false; + _lastMicChunkAt = null; + if (!_isStopping && mounted) { + await _startMicCapture(); + } + } finally { + _isRestartingMic = false; } } Future _stopAll() async { _isStopping = true; + _bargeInProbeTimer?.cancel(); + _bargeInProbeTimer = null; + if (_bargeInProbeActive) { + try { + await _audioEventsChannel.invokeMethod('endOutputProbe'); + } catch (_) {} + _bargeInProbeActive = false; + } _isConnecting = false; _connectionGeneration++; _reconnectTimer?.cancel(); _reconnectTimer = null; + _micHealthTimer?.cancel(); + _micHealthTimer = null; _playbackPumpTimer?.cancel(); _playbackCompletionTimer?.cancel(); _pendingPlaybackChunks.clear(); + _playbackReferences.clear(); _estimatedPlaybackEnd = null; _serverAudioDone = false; + _playbackSegmentSealed = false; + _playbackSegmentStarted = false; + _pendingPlaybackSampleCount = 0; + _responseLifecycleDone = false; _resetBargeInDetector(); try { await _audioStreamSubscription?.cancel(); @@ -947,6 +2061,13 @@ class _AiVoiceChatDialogState extends State await _audioRecorder.stop(); } } catch (_) {} + // stop() alone leaves the native recorder (and Android's mic-in-use + // indicator) alive — the OS keeps the capture session open until the + // plugin instance is disposed. _stopAll only runs on terminal teardown + // (close button / dispose), so releasing the recorder here is safe. + try { + await _audioRecorder.dispose(); + } catch (_) {} // Bump generation + flip flag BEFORE stopping player — any in-flight // _playPcmChunk sees them and bails out before calling into native. _playerGeneration++; @@ -966,138 +2087,554 @@ class _AiVoiceChatDialogState extends State _ws = null; _conversationId = null; _isRecording = false; + _isStartingRecorder = false; _isSessionReady = false; + _micBufferBeforeReady.clear(); + _micBufferedBytes = 0; _isUserAudioLocked = false; _unlockUserMicTimer?.cancel(); } @override void dispose() { + _audioEventsChannel.setMethodCallHandler(null); _orbController.dispose(); _rippleController.dispose(); _waveController.dispose(); + _transcriptController.dispose(); _unlockUserMicTimer?.cancel(); _reconnectTimer?.cancel(); + _micHealthTimer?.cancel(); _playbackPumpTimer?.cancel(); _playbackCompletionTimer?.cancel(); + _turnWatchdog?.cancel(); _stopAll(); super.dispose(); } // ----- UI --------------------------------------------------------- + static const Color _brandAccent = Color(0xFF8EA9FF); + static const Color _dangerAccent = Color(0xFFFF7A86); + + _VoiceVisualState get _visualState { + final status = _statusText; + final hasError = status.contains('خطا') || + status.contains('ناموفق') || + status.contains('رد شد'); + if (hasError) return _VoiceVisualState.error; + if (!_isSessionReady || _isConnecting) { + return _VoiceVisualState.connecting; + } + if (status.contains('جست‌وجو')) return _VoiceVisualState.searching; + if (status.contains('آماده‌سازی')) return _VoiceVisualState.thinking; + if (_isAiSpeaking) return _VoiceVisualState.speaking; + if (_isMicMuted) return _VoiceVisualState.muted; + if (_isUserSpeaking) return _VoiceVisualState.hearing; + return _VoiceVisualState.listening; + } + + String _titleForState(_VoiceVisualState state) { + return switch (state) { + _VoiceVisualState.connecting => 'در حال اتصال', + _VoiceVisualState.listening => 'گوش می‌دهم', + _VoiceVisualState.hearing => 'صدایتان را می‌شنوم', + _VoiceVisualState.searching => 'در حال جست‌وجوی وب', + _VoiceVisualState.thinking => 'در حال آماده‌سازی پاسخ', + _VoiceVisualState.speaking => 'هوشان در حال صحبت است', + _VoiceVisualState.muted => 'میکروفن خاموش است', + _VoiceVisualState.error => 'مشکلی پیش آمده', + }; + } + + String _descriptionForState(_VoiceVisualState state) { + return switch (state) { + _VoiceVisualState.connecting => 'در حال آماده‌سازی گفت‌وگوی امن', + _VoiceVisualState.listening => 'سؤال‌تان را طبیعی و واضح بپرسید', + _VoiceVisualState.hearing => 'بعد از مکث کوتاه، پاسخ می‌دهم', + _VoiceVisualState.searching => 'در حال بررسی تازه‌ترین منابع', + _VoiceVisualState.thinking => 'نتایج در حال تبدیل‌شدن به پاسخ هستند', + _VoiceVisualState.speaking => 'برای صحبت، پاسخ را قطع کنید', + _VoiceVisualState.muted => 'برای ادامه، میکروفن را روشن کنید', + _VoiceVisualState.error => _statusText, + }; + } + + IconData _iconForState(_VoiceVisualState state) { + return switch (state) { + _VoiceVisualState.connecting => Icons.sync_rounded, + _VoiceVisualState.listening => Icons.hearing_rounded, + _VoiceVisualState.hearing => Icons.graphic_eq_rounded, + _VoiceVisualState.searching => Icons.travel_explore_rounded, + _VoiceVisualState.thinking => Icons.auto_awesome_rounded, + _VoiceVisualState.speaking => Icons.graphic_eq_rounded, + _VoiceVisualState.muted => Icons.mic_off_rounded, + _VoiceVisualState.error => Icons.error_outline_rounded, + }; + } + + Color _accentForState(_VoiceVisualState state) { + return switch (state) { + _VoiceVisualState.error => _dangerAccent, + _VoiceVisualState.muted => const Color(0xFF9BA1B2), + _ => _brandAccent, + }; + } + @override Widget build(BuildContext context) { - final theme = Theme.of(context); - return BackdropFilter( - filter: ImageFilter.blur(sigmaX: 8, sigmaY: 8), - child: Dialog( - backgroundColor: theme.colorScheme.surface.withOpacity(0.95), - insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 32), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)), - child: SizedBox( - width: double.infinity, - height: 480, - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - DidvanText( - 'گفتگوی صوتی با هوشان', - fontSize: 16, - fontWeight: FontWeight.bold, - color: theme.colorScheme.primary, - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () async { - await _stopAll(); - if (mounted) Navigator.of(context).pop(); - }, - ), - ], + final visualState = _visualState; + final accent = _accentForState(visualState); + final reduceMotion = MediaQuery.disableAnimationsOf(context); + final activity = _isAiSpeaking + ? _aiAudioLevel + : (_isUserSpeaking ? _userAudioLevel : 0.0); + + return Dialog.fullscreen( + backgroundColor: const Color(0xFF06070B), + child: Directionality( + textDirection: TextDirection.rtl, + child: Stack( + children: [ + const Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Color(0xFF0A0C13), + Color(0xFF06070B), + Color(0xFF08090D), + ], + ), ), - const SizedBox(height: 16), - Expanded(child: _buildOrb()), - const SizedBox(height: 12), - _buildWave(), - const SizedBox(height: 12), - DidvanText( - _statusText, - fontSize: 13, - color: theme.colorScheme.onSurface.withOpacity(0.7), - ), - const SizedBox(height: 12), - ], + ), ), - ), + Positioned.fill( + child: AnimatedBuilder( + animation: _orbController, + builder: (_, __) => CustomPaint( + painter: _AtmospherePainter( + accent: accent, + time: _orbController.value, + activity: activity, + ), + ), + ), + ), + SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final isCompact = constraints.maxHeight < 720; + final hasTranscript = + _messages.isNotEmpty || _liveAiTranscript.isNotEmpty; + final orbAreaHeight = isCompact + ? (hasTranscript ? 176.0 : 210.0) + : (hasTranscript + ? (constraints.maxHeight * 0.27).clamp(190.0, 250.0) + : (constraints.maxHeight * 0.32).clamp(220.0, 285.0)); + final contentWidth = + math.min(constraints.maxWidth, 680.0).toDouble(); + final orbSize = math + .min(contentWidth * 0.62, orbAreaHeight) + .clamp(170.0, 260.0) + .toDouble(); + + return Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: contentWidth, + child: Column( + children: [ + _buildHeader(), + AnimatedContainer( + duration: const Duration(milliseconds: 420), + curve: Curves.easeOutCubic, + height: orbAreaHeight, + child: _buildOrb( + accent, + orbSize, + reduceMotion: reduceMotion, + ), + ), + _buildVoiceStatus(visualState, accent), + SizedBox(height: isCompact ? 8 : 14), + Expanded(child: _buildTranscript(accent)), + _buildBottomControls(accent), + ], + ), + ), + ); + }, + ), + ), + ], ), ), ); } - Widget _buildOrb() { + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.045), + borderRadius: BorderRadius.circular(13), + border: Border.all(color: Colors.white.withOpacity(0.07)), + ), + child: const Icon( + Icons.auto_awesome_rounded, + color: _brandAccent, + size: 19, + ), + ), + const SizedBox(width: 12), + const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'هوشان', + style: TextStyle( + color: Colors.white, + fontSize: 17, + fontWeight: FontWeight.w700, + ), + ), + Text( + 'دستیار صوتی دیدوان', + style: TextStyle( + color: Color(0xFF9298A8), + fontSize: 12, + height: 1.45, + ), + ), + ], + ), + ], + ), + Semantics( + button: true, + label: 'بستن گفت‌وگو', + child: IconButton( + tooltip: 'بستن', + style: IconButton.styleFrom( + minimumSize: const Size(44, 44), + backgroundColor: Colors.white.withOpacity(0.045), + side: BorderSide(color: Colors.white.withOpacity(0.07)), + ), + icon: const Icon( + Icons.close_rounded, + color: Color(0xFFC3C7D2), + ), + onPressed: _closeVoiceChat, + ), + ), + ], + ), + ); + } + + Widget _buildTranscript(Color accent) { + final itemCount = _messages.length + (_liveAiTranscript.isNotEmpty ? 1 : 0); + final visibleMessages = <_VoiceMessage>[ + ..._messages, + if (_liveAiTranscript.isNotEmpty) _VoiceMessage(_liveAiTranscript), + ]; + return Container( + margin: const EdgeInsets.fromLTRB(18, 0, 18, 6), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white.withOpacity(0.045), + Colors.white.withOpacity(0.022), + ], + ), + borderRadius: BorderRadius.circular(26), + border: Border.all(color: Colors.white.withOpacity(0.065)), + ), + child: itemCount == 0 + ? Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.format_quote_rounded, + color: accent.withOpacity(0.55), + size: 26, + ), + const SizedBox(height: 10), + Text( + _isSessionReady + ? 'متن پاسخ هوشان اینجا نمایش داده می‌شود' + : 'گفت‌وگو تا چند لحظه دیگر آماده است', + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF9A9FAD), + fontSize: 13, + height: 1.65, + ), + ), + ], + ), + ), + ) + : ListView.separated( + controller: _transcriptController, + padding: const EdgeInsets.fromLTRB(20, 20, 20, 24), + itemCount: visibleMessages.length, + separatorBuilder: (_, __) => const SizedBox(height: 20), + itemBuilder: (context, index) { + final isCurrent = index == visibleMessages.length - 1; + return AnimatedOpacity( + duration: const Duration(milliseconds: 280), + opacity: isCurrent ? 1 : 0.48, + child: Text( + visibleMessages[index].text, + textDirection: TextDirection.rtl, + style: TextStyle( + color: isCurrent + ? const Color(0xFFE8EAF0) + : const Color(0xFFC3C6D0), + fontSize: isCurrent ? 15 : 14, + height: 1.68, + fontWeight: isCurrent ? FontWeight.w500 : FontWeight.w400, + ), + ), + ); + }, + ), + ); + } + + Widget _buildBottomControls(Color accent) { + return Container( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSize( + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + child: _isAiSpeaking + ? Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: _handleUserTakeover, + borderRadius: BorderRadius.circular(15), + child: Ink( + height: 46, + padding: const EdgeInsets.symmetric(horizontal: 18), + decoration: BoxDecoration( + color: accent.withOpacity(0.11), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: accent.withOpacity(0.26), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.mic_rounded, + color: accent, + size: 20, + ), + const SizedBox(width: 9), + const Text( + 'قطع پاسخ و شروع صحبت', + style: TextStyle( + color: Color(0xFFE8EBF5), + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + ), + ), + ) + : const SizedBox.shrink(), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildRoundControl( + icon: _isMicMuted + ? Icons.mic_off_rounded + : Icons.mic_none_rounded, + label: _isMicMuted ? 'روشن‌کردن میکروفن' : 'میکروفن', + color: _isMicMuted ? _dangerAccent : accent, + filled: _isMicMuted, + onTap: _toggleMicrophone, + ), + const SizedBox(width: 22), + _buildRoundControl( + icon: Icons.call_end_rounded, + label: 'پایان', + color: _dangerAccent, + filled: true, + onTap: _closeVoiceChat, + ), + ], + ), + ], + ), + ); + } + + Widget _buildRoundControl({ + required IconData icon, + required String label, + required Color color, + required bool filled, + required VoidCallback onTap, + }) { + return Semantics( + button: true, + label: label, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + customBorder: const CircleBorder(), + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), + width: 58, + height: 58, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: filled + ? color.withOpacity(0.18) + : Colors.white.withOpacity(0.045), + border: Border.all( + color: filled + ? color.withOpacity(0.5) + : Colors.white.withOpacity(0.085), + ), + ), + child: Icon(icon, color: color, size: 24), + ), + ), + ), + const SizedBox(height: 8), + Text( + label, + style: TextStyle( + color: filled ? const Color(0xFFCBCFD9) : const Color(0xFFADB2C0), + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } + + Widget _buildOrb( + Color accent, + double size, { + required bool reduceMotion, + }) { return AnimatedBuilder( animation: Listenable.merge([_orbController, _rippleController]), builder: (context, _) { - final orbScale = - 1 + (_isAiSpeaking ? 0.15 : 0.05) * _orbController.value; - final rippleRadius = 80 + 60 * _rippleController.value; - final rippleOpacity = (1 - _rippleController.value) * - (_isAiSpeaking || _isUserSpeaking ? 0.7 : 0.3); - final color = _isUserSpeaking - ? Colors.greenAccent - : (_isAiSpeaking - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.secondary); + final time = reduceMotion ? 0.0 : _rippleController.value; + final voiceEnergy = _orbEnvelope; + final transient = reduceMotion ? 0.0 : _orbTransient; + final texture = reduceMotion ? 0.0 : _orbTexture; + final motionPhase = reduceMotion ? 0.0 : _orbMotionPhase; + final shaderActivity = (0.035 + voiceEnergy * 0.965).clamp(0.0, 1.0); + final scaleX = reduceMotion + ? 1.0 + : 1 + + voiceEnergy * 0.018 + + transient * 0.024 + + _orbVelocity * 0.018; + final scaleY = reduceMotion + ? 1.0 + : 1 + + voiceEnergy * 0.034 - + transient * 0.011 - + _orbVelocity * 0.025; + final opticalDrift = reduceMotion + ? Offset.zero + : Offset( + math.sin(motionPhase * 0.73) * voiceEnergy * 3.2 + + math.sin(motionPhase * 1.91) * texture * 0.8, + -voiceEnergy * 1.7 + + math.cos(motionPhase * 0.83) * voiceEnergy * 1.8 + + _orbVelocity * 24 + + transient * math.sin(motionPhase * 1.37) * 3.6, + ); + final tilt = reduceMotion + ? 0.0 + : math.sin(motionPhase * 0.51) * voiceEnergy * 0.008 + + _orbVelocity * 0.021; return GestureDetector( - onTap: - _isAiSpeaking ? () => _interruptAi(clearInputBuffer: true) : null, - child: SizedBox( - width: 220, - height: 220, - child: Stack( - alignment: Alignment.center, - children: [ - Container( - width: rippleRadius * 2, - height: rippleRadius * 2, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: color.withOpacity(rippleOpacity), width: 2), - ), - ), - Transform.scale( - scale: orbScale, - child: Container( - width: 120, - height: 120, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - color.withOpacity(0.9), - color.withOpacity(0.5), - color.withOpacity(0.2), - ], + onTap: _isAiSpeaking ? _handleUserTakeover : null, + child: Center( + child: SizedBox( + width: size, + height: size, + child: Stack( + alignment: Alignment.center, + children: [ + Transform.translate( + offset: opticalDrift, + child: Transform.rotate( + angle: tilt, + child: Transform.scale( + scaleX: scaleX, + scaleY: scaleY, + child: RepaintBoundary( + child: SizedBox( + width: size, + height: size, + child: CustomPaint( + painter: _orbShader != null + ? _OrbShaderPainter( + shader: _orbShader!, + accent: accent, + time: time, + activity: shaderActivity, + transient: transient, + texture: texture, + motionPhase: motionPhase, + ) + : _LuminousOrbPainter( + accent: accent, + time: 0, + activity: 0, + ), + ), + ), + ), ), ), - child: Icon( - _isUserSpeaking - ? Icons.mic - : (_isAiSpeaking - ? Icons.graphic_eq - : Icons.smart_toy), - size: 48, - color: Colors.white, - ), ), - ), - ], + ], + ), ), ), ); @@ -1105,26 +2642,362 @@ class _AiVoiceChatDialogState extends State ); } - Widget _buildWave() { - return SizedBox( - height: 40, - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: List.generate(_audioWaveHeights.length, (i) { - final h = (_audioWaveHeights[i] * 40).clamp(2.0, 40.0); - return AnimatedContainer( - duration: const Duration(milliseconds: 80), - width: 3, - height: h, - decoration: BoxDecoration( - color: _isUserSpeaking - ? Colors.greenAccent - : Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(2), + Widget _buildVoiceStatus(_VoiceVisualState state, Color accent) { + final isReactive = state == _VoiceVisualState.speaking || + state == _VoiceVisualState.hearing; + return AnimatedSwitcher( + duration: const Duration(milliseconds: 240), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + child: Column( + key: ValueKey(state), + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (isReactive) + SizedBox( + width: 25, + height: 18, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: List.generate(3, (index) { + final waveIndex = + ((index + 1) * _audioWaveHeights.length ~/ 4) + .clamp(0, _audioWaveHeights.length - 1); + final value = state == _VoiceVisualState.speaking + ? _audioWaveHeights[waveIndex] + : _userAudioLevel; + return AnimatedContainer( + duration: const Duration(milliseconds: 70), + width: 3, + height: (5 + value * 13).clamp(5.0, 18.0), + decoration: BoxDecoration( + color: accent, + borderRadius: BorderRadius.circular(3), + ), + ); + }), + ), + ) + else + Icon(_iconForState(state), color: accent, size: 19), + const SizedBox(width: 9), + Text( + _titleForState(state), + style: const TextStyle( + color: Color(0xFFF0F1F5), + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + const SizedBox(height: 5), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Text( + _descriptionForState(state), + maxLines: state == _VoiceVisualState.error ? 2 : 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color(0xFF989EAD), + fontSize: 12, + height: 1.5, + ), ), - ); - }), + ), + ], ), ); } } + +class _OrbShaderPainter extends CustomPainter { + const _OrbShaderPainter({ + required this.shader, + required this.accent, + required this.time, + required this.activity, + required this.transient, + required this.texture, + required this.motionPhase, + }); + + final ui.FragmentShader shader; + final Color accent; + final double time; + final double activity; + final double transient; + final double texture; + final double motionPhase; + + @override + void paint(Canvas canvas, Size size) { + shader + ..setFloat(0, size.width) + ..setFloat(1, size.height) + ..setFloat(2, time) + ..setFloat(3, activity.clamp(0, 1)) + ..setFloat(4, accent.r) + ..setFloat(5, accent.g) + ..setFloat(6, accent.b) + ..setFloat(7, transient.clamp(0, 1)) + ..setFloat(8, texture.clamp(0, 1)) + ..setFloat(9, motionPhase); + canvas.drawRect(Offset.zero & size, Paint()..shader = shader); + } + + @override + bool shouldRepaint(covariant _OrbShaderPainter old) => + old.time != time || + old.accent != accent || + old.activity != activity || + old.transient != transient || + old.texture != texture || + old.motionPhase != motionPhase; +} + +class _LuminousOrbPainter extends CustomPainter { + final Color accent; + final double time; + final double activity; + + _LuminousOrbPainter({ + required this.accent, + required this.time, + required this.activity, + }); + + @override + void paint(Canvas canvas, Size size) { + final center = Offset(size.width / 2, size.height / 2); + final r = math.min(size.width, size.height) * 0.285; + final phase = time * math.pi * 2; + + for (var i = 3; i >= 0; i--) { + final haloRadius = r * (1.15 + i * 0.25 + activity * 0.2); + canvas.drawCircle( + center, + haloRadius, + Paint() + ..color = accent.withOpacity(0.025 + activity * 0.018) + ..maskFilter = MaskFilter.blur(BlurStyle.normal, 22 + i * 10), + ); + } + + final shadowRect = Rect.fromCenter( + center: Offset(center.dx, center.dy + r * 1.15), + width: r * 1.7, + height: r * 0.22, + ); + canvas.drawOval( + shadowRect, + Paint() + ..shader = RadialGradient( + colors: [accent.withOpacity(0.22), Colors.transparent], + ).createShader(shadowRect) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 14), + ); + + final orb = Path(); + const points = 120; + for (var i = 0; i <= points; i++) { + final a = i / points * math.pi * 2; + final deformation = activity * + (0.025 * math.sin(a * 3 + phase * 2.2) + + 0.014 * math.sin(a * 5 - phase * 3.1) + + 0.008 * math.sin(a * 9 + phase)); + final radius = r * (1 + deformation); + final point = center + Offset(math.cos(a) * radius, math.sin(a) * radius); + if (i == 0) { + orb.moveTo(point.dx, point.dy); + } else { + orb.lineTo(point.dx, point.dy); + } + } + orb.close(); + final bodyRect = Rect.fromCircle(center: center, radius: r * 1.08); + canvas.drawPath( + orb, + Paint() + ..shader = RadialGradient( + center: const Alignment(-0.42, -0.48), + radius: 1.18, + colors: [ + const Color(0xFFE9F4FF).withOpacity(0.96), + Color.lerp(accent, const Color(0xFF6B78B8), 0.38)!, + Color.lerp(accent, const Color(0xFF111528), 0.72)!, + const Color(0xFF03050A), + ], + stops: const [0, 0.22, 0.63, 1], + ).createShader(bodyRect), + ); + + canvas.save(); + canvas.clipPath(orb); + final innerRect = Rect.fromCircle(center: center, radius: r * 0.96); + canvas.drawCircle( + center, + r * 0.94, + Paint() + ..shader = SweepGradient( + transform: GradientRotation(phase * 0.45), + colors: [ + Colors.transparent, + accent.withOpacity(0.12 + activity * 0.18), + const Color(0xFFB9E7FF).withOpacity(0.24), + Colors.transparent, + const Color(0xFFD9C6FF).withOpacity(0.14), + Colors.transparent, + ], + stops: const [0, 0.2, 0.36, 0.55, 0.76, 1], + ).createShader(innerRect) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 12), + ); + + for (var i = 0; i < 4; i++) { + final y = center.dy - r * 0.55 + i * r * 0.34; + final rect = Rect.fromCenter( + center: Offset(center.dx + math.sin(phase + i) * r * 0.08, y), + width: r * (1.65 - i * 0.08), + height: r * 0.38, + ); + canvas.drawArc( + rect, + math.pi * (0.08 + i * 0.1) + phase * 0.12, + math.pi * (0.75 + activity * 0.22), + false, + Paint() + ..color = Colors.white.withOpacity(0.055 + activity * 0.045) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.2 + activity * 1.8 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 2), + ); + } + + final glassReflection = Rect.fromCenter( + center: Offset(center.dx - r * 0.34, center.dy - r * 0.38), + width: r * 0.62, + height: r * 1.05, + ); + canvas.drawOval( + glassReflection, + Paint() + ..shader = LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withOpacity(0.52), + Colors.white.withOpacity(0.07), + Colors.transparent, + ], + stops: const [0, 0.34, 1], + ).createShader(glassReflection) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 6), + ); + canvas.restore(); + + canvas.drawPath( + orb, + Paint() + ..shader = SweepGradient( + transform: GradientRotation(phase * 0.12), + colors: [ + Colors.white.withOpacity(0.74), + accent.withOpacity(0.12), + Colors.transparent, + accent.withOpacity(0.7), + Colors.white.withOpacity(0.74), + ], + ).createShader(bodyRect) + ..style = PaintingStyle.stroke + ..strokeWidth = 1.15 + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 1.4), + ); + + final hotSpot = Offset(center.dx - r * 0.42, center.dy - r * 0.52); + canvas.drawCircle( + hotSpot, + r * 0.19, + Paint() + ..shader = RadialGradient( + colors: [ + Colors.white.withOpacity(0.92), + Colors.white.withOpacity(0.18), + Colors.transparent, + ], + stops: const [0, 0.26, 1], + ).createShader(Rect.fromCircle(center: hotSpot, radius: r * 0.19)), + ); + } + + @override + bool shouldRepaint(covariant _LuminousOrbPainter old) => + old.time != time || old.accent != accent || old.activity != activity; +} + +class _AtmospherePainter extends CustomPainter { + const _AtmospherePainter({ + required this.accent, + required this.time, + required this.activity, + }); + + final Color accent; + final double time; + final double activity; + + @override + void paint(Canvas canvas, Size size) { + final orbCenter = Offset(size.width / 2, size.height * 0.28); + final glowRect = Rect.fromCircle( + center: orbCenter, + radius: size.width * (0.58 + activity * 0.08), + ); + canvas.drawCircle( + orbCenter, + glowRect.width / 2, + Paint() + ..shader = RadialGradient( + colors: [ + accent.withOpacity(0.07 + activity * 0.035), + accent.withOpacity(0.016), + Colors.transparent, + ], + stops: const [0, 0.42, 1], + ).createShader(glowRect), + ); + + final leftLight = Rect.fromCenter( + center: Offset(-size.width * 0.02, size.height * 0.26), + width: size.width * 0.42, + height: size.height * 0.8, + ); + canvas.drawOval( + leftLight, + Paint() + ..color = const Color(0xFF6688C8).withOpacity(0.012) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 55), + ); + + final rightLight = Rect.fromCenter( + center: Offset(size.width * 1.03, size.height * 0.47), + width: size.width * 0.46, + height: size.height * 0.74, + ); + canvas.drawOval( + rightLight, + Paint() + ..color = accent.withOpacity(0.01 + activity * 0.006) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 60), + ); + } + + @override + bool shouldRepaint(covariant _AtmospherePainter old) => + old.accent != accent || old.activity != activity; +} diff --git a/pubspec.yaml b/pubspec.yaml index 6466542..c7b4ed8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -142,6 +142,7 @@ dev_dependencies: flutter: shaders: - lib/shaders/liquid_glass_lens.frag + - lib/shaders/ai_orb_3d.frag # - lib/shaders/static_liquid.frag # - lib/shaders/water_ripple.frag # The following line ensures that the Material Icons font is