Procházet zdrojové kódy

feat(flutter): add ReplyPartialMessage to handle streaming LLM replies

- New ReplyPartialMessage class for reply_partial type
- liveReply field in RecordingState for real-time AI response display
- Handle ReplyPartialMessage in _listenToMessages switch
wenhongquan před 3 týdny
rodič
revize
4ffc9e9139
32 změnil soubory, kde provedl 1059 přidání a 238 odebrání
  1. 58 0
      .dockerignore
  2. 6 0
      asr_agent/Dockerfile
  3. 8 0
      asr_agent/Dockerfile.base
  4. binární
      asr_agent/__pycache__/conversation_worker.cpython-313.pyc
  5. 70 0
      asr_agent/agent.py
  6. 130 0
      asr_agent/qwen_stt.py
  7. 5 0
      asr_agent/requirements.txt
  8. 40 0
      flutter_asr_client/ios/Podfile.lock
  9. 1 4
      flutter_asr_client/lib/common/constants.dart
  10. 10 1
      flutter_asr_client/lib/main.dart
  11. 20 0
      flutter_asr_client/lib/models/websocket_message.dart
  12. 45 24
      flutter_asr_client/lib/pages/profile/widgets/server_settings_dialog.dart
  13. 76 165
      flutter_asr_client/lib/pages/recording/notifiers/recording_notifier.dart
  14. 48 1
      flutter_asr_client/lib/pages/recording/widgets/conversation_list.dart
  15. 18 0
      flutter_asr_client/lib/providers/livekit_providers.dart
  16. 5 0
      flutter_asr_client/lib/providers/settings_providers.dart
  17. 131 0
      flutter_asr_client/lib/services/livekit_service.dart
  18. 15 0
      flutter_asr_client/lib/services/settings_service.dart
  19. 8 0
      flutter_asr_client/linux/flutter/generated_plugin_registrant.cc
  20. 3 0
      flutter_asr_client/linux/flutter/generated_plugins.cmake
  21. 10 0
      flutter_asr_client/macos/Flutter/GeneratedPluginRegistrant.swift
  22. 40 0
      flutter_asr_client/macos/Podfile.lock
  23. 264 0
      flutter_asr_client/pubspec.lock
  24. 2 0
      flutter_asr_client/pubspec.yaml
  25. 9 0
      flutter_asr_client/windows/flutter/generated_plugin_registrant.cc
  26. 4 0
      flutter_asr_client/windows/flutter/generated_plugins.cmake
  27. 12 0
      livekit-server/docker-compose.yml
  28. 9 0
      livekit-server/livekit.yaml
  29. binární
      whisper-qt-client/whisper_asr/__pycache__/audio_processor.cpython-310.pyc
  30. binární
      whisper-qt-client/whisper_asr/__pycache__/qwen_engine.cpython-310.pyc
  31. binární
      whisper-qt-client/whisper_asr/__pycache__/transcript_processor.cpython-310.pyc
  32. 12 43
      whisper-qt-client/whisper_asr/qwen_engine.py

+ 58 - 0
.dockerignore

@@ -0,0 +1,58 @@
+# Git / VCS
+.git
+.gitignore
+
+# IDE / editor
+.vscode/
+.idea/
+.claude/
+.codegraph/
+.qoder/
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Secrets / env
+.env
+.env.*
+*.pem
+*.key
+
+# Logs
+*.log
+logs/
+
+# Python cache
+__pycache__/
+*.pyc
+*.pyo
+*.egg-info/
+.venv/
+venv/
+
+# Build artifacts
+build/
+dist/
+*.spec
+
+# Node / Flutter
+node_modules/
+.dart_tool/
+flutter_asr_client/build/
+flutter_asr_client/.dart_tool/
+
+# Qt client build outputs / venv
+whisper-qt-client/venv/
+whisper-qt-client/build/
+whisper-qt-client/client/
+tts-qt-client/client/
+
+# Docker
+Dockerfile*
+docker-compose*.yml
+.dockerignore
+
+# Docs
+README.md
+*.md

+ 6 - 0
asr_agent/Dockerfile

@@ -0,0 +1,6 @@
+FROM k8s.device.wenhq.top:8583/docker_r/asr-base:latest
+
+COPY whisper_asr/ ./whisper_asr/
+COPY conversation_worker.py ./worker.py
+
+CMD ["python3", "worker.py", "--room", "asr-test"]

+ 8 - 0
asr_agent/Dockerfile.base

@@ -0,0 +1,8 @@
+FROM docker.1ms.run/python:3.10-slim
+
+RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/
+RUN pip install --no-cache-dir torch qwen-asr livekit pyjwt numpy aiohttp
+
+WORKDIR /app
+ENTRYPOINT []
+CMD ["python3", "-c", "from qwen_asr import Qwen3ASRModel; print('base ok')"]

binární
asr_agent/__pycache__/conversation_worker.cpython-313.pyc


+ 70 - 0
asr_agent/agent.py

@@ -0,0 +1,70 @@
+"""
+LiveKit ASR Agent — publishes user speech transcriptions to the room.
+"""
+
+from __future__ import annotations
+
+import logging
+
+from livekit import agents, rtc
+from livekit.agents import Agent, AgentSession, JobContext, cli, inference
+from livekit.agents.inference import TurnDetector
+from livekit.plugins import noise_cancellation, silero
+
+from qwen_stt import QwenSTT
+
+logger = logging.getLogger("asr-agent")
+
+LIVEKIT_URL = "ws://localhost:7880"
+LIVEKIT_API_KEY = "devkey"
+LIVEKIT_API_SECRET = "secretsecretsecretsecretsecret12"
+
+
+class ASRAgent(Agent):
+    """
+    Speech-to-text agent. Transcribes user speech and sends results
+    back to the room via data messages / agent utterances.
+
+    No LLM or TTS — pure transcription for now.
+    """
+
+    def __init__(self) -> None:
+        super().__init__(
+            instructions="将用户语音实时转写为文字。"
+        )
+
+    async def on_enter(self):
+        # Send a welcome message to the room chat
+        await self.session.send_utterance(
+            instructions="ASR 转写已就绪,请开始说话。"
+        )
+
+
+server = agents.AgentServer()
+
+
+@server.rtc_session()
+async def entrypoint(ctx: JobContext):
+    logger.info(f"new session: room={ctx.room.name}")
+
+    session = AgentSession(
+        stt=QwenSTT(
+            model_id="Qwen/Qwen3-ASR-0.6B",
+            silence_repeats=3,
+            silence_seconds=3.0,
+        ),
+        vad=inference.VAD(),
+        turn_detection=TurnDetector(),
+    )
+
+    await session.start(
+        agent=ASRAgent(),
+        room=ctx.room,
+        room_input_options=agents.RoomInputOptions(
+            noise_cancellation=noise_cancellation.BVC(),
+        ),
+    )
+
+
+if __name__ == "__main__":
+    cli.run_app(server)

+ 130 - 0
asr_agent/qwen_stt.py

@@ -0,0 +1,130 @@
+"""
+Custom LiveKit STT plugin wrapping the local Qwen3-ASR model.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import contextlib
+import logging
+import sys
+import os
+
+from livekit import agents, rtc
+from livekit.agents import stt, utils
+
+# Add parent to path so we can import the existing whisper_asr modules.
+sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
+from whisper_qt_client.whisper_asr.audio_processor import AudioBuffer
+from whisper_qt_client.whisper_asr.qwen_engine import QwenASREngine
+from whisper_qt_client.whisper_asr.transcript_processor import TranscriptProcessor
+
+logger = logging.getLogger("qwen-stt")
+
+
+class QwenSTT(stt.STT):
+    def __init__(
+        self,
+        model_id: str = "Qwen/Qwen3-ASR-0.6B",
+        language: str | None = None,
+        silence_repeats: int = 3,
+        silence_seconds: float = 3.0,
+    ):
+        super().__init__(
+            capabilities=stt.STTCapabilities(
+                streaming=True,
+                interim_results=True,
+            ),
+            sample_rate=16000,
+            num_channels=1,
+        )
+        self._model_id = model_id
+        self._language = language
+        self._silence_repeats = silence_repeats
+        self._silence_seconds = silence_seconds
+
+    async def _recognize_impl(self, buffer: utils.AudioBuffer) -> stt.SpeechData:
+        raise NotImplementedError("Use streaming recognition via _stream_impl")
+
+    async def _stream_impl(
+        self,
+        audio_stream: utils.AudioInputStream,
+        output_queue: asyncio.Queue[stt.SpeechEvent],
+    ) -> None:
+        """
+        Continuously transcribes incoming audio frames and pushes
+        SpeechEvent to the output queue.
+        """
+        engine = QwenASREngine(model_id=self._model_id, language=self._language)
+        processor = TranscriptProcessor(
+            silence_repeats=self._silence_repeats,
+            silence_seconds=self._silence_seconds,
+        )
+        audio_buffer = AudioBuffer(max_duration=10.0, sample_rate=16000)
+        transcribe_interval = 2.0  # seconds between transcription calls
+
+        async def _transcribe():
+            while True:
+                await asyncio.sleep(transcribe_interval)
+                audio = audio_buffer.get_last(5.0)
+                if len(audio) <= 1600:
+                    continue
+
+                # Run in executor to avoid blocking the event loop
+                loop = asyncio.get_running_loop()
+                try:
+                    result = await loop.run_in_executor(
+                        None, engine.transcribe_array, audio
+                    )
+                except Exception:
+                    logger.exception("transcription failed")
+                    continue
+
+                raw_text = result.get("text", "").strip()
+                if not raw_text:
+                    continue
+
+                messages = processor.feed(raw_text)
+                for msg in messages:
+                    if msg["type"] == "partial":
+                        output_queue.put_nowait(stt.SpeechEvent(
+                            type=stt.SpeechEventType.INTERIM_TRANSCRIPT,
+                            alternatives=[
+                                stt.SpeechData(
+                                    text=msg["text"],
+                                    language=result.get("language", ""),
+                                    is_final=False,
+                                )
+                            ],
+                        ))
+                    elif msg["type"] == "utterance":
+                        output_queue.put_nowait(stt.SpeechEvent(
+                            type=stt.SpeechEventType.FINAL_TRANSCRIPT,
+                            alternatives=[
+                                stt.SpeechData(
+                                    text=msg["text"],
+                                    language=result.get("language", ""),
+                                    is_final=True,
+                                )
+                            ],
+                        ))
+
+        # Accumulate audio frames into our AudioBuffer
+        async def _accumulate():
+            with contextlib.closing(audio_stream):
+                async for frame in audio_stream:
+                    # frame is an rtc.AudioFrame
+                    data = frame.data
+                    # data is bytes (int16 PCM)
+                    import numpy as np
+                    array = np.frombuffer(data, dtype=np.int16).astype(np.float32) / 32768.0
+                    audio_buffer.append(array)
+
+        # Run both coroutines concurrently; stop accumulating when transcribe ends
+        transcribe_task = asyncio.create_task(_transcribe())
+        try:
+            await _accumulate()
+        finally:
+            transcribe_task.cancel()
+            with contextlib.suppress(asyncio.CancelledError):
+                await transcribe_task

+ 5 - 0
asr_agent/requirements.txt

@@ -0,0 +1,5 @@
+torch==2.5.1
+numpy
+livekit>=1.0.0
+pyjwt>=2.0.0
+aiohttp

+ 40 - 0
flutter_asr_client/ios/Podfile.lock

@@ -1,7 +1,21 @@
 PODS:
 PODS:
+  - connectivity_plus (0.0.1):
+    - Flutter
+  - device_info_plus (0.0.1):
+    - Flutter
   - Flutter (1.0.0)
   - Flutter (1.0.0)
+  - flutter_webrtc (1.4.0):
+    - Flutter
+    - WebRTC-SDK (= 144.7559.01)
   - integration_test (0.0.1):
   - integration_test (0.0.1):
     - Flutter
     - Flutter
+  - livekit_client (2.8.1):
+    - Flutter
+    - flutter_webrtc
+    - WebRTC-SDK (= 144.7559.01)
+  - path_provider_foundation (0.0.1):
+    - Flutter
+    - FlutterMacOS
   - permission_handler_apple (9.4.8):
   - permission_handler_apple (9.4.8):
     - Flutter
     - Flutter
   - record_ios (1.2.1):
   - record_ios (1.2.1):
@@ -9,19 +23,39 @@ PODS:
   - shared_preferences_foundation (0.0.1):
   - shared_preferences_foundation (0.0.1):
     - Flutter
     - Flutter
     - FlutterMacOS
     - FlutterMacOS
+  - WebRTC-SDK (144.7559.01)
 
 
 DEPENDENCIES:
 DEPENDENCIES:
+  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
+  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
   - Flutter (from `Flutter`)
   - Flutter (from `Flutter`)
+  - flutter_webrtc (from `.symlinks/plugins/flutter_webrtc/ios`)
   - integration_test (from `.symlinks/plugins/integration_test/ios`)
   - integration_test (from `.symlinks/plugins/integration_test/ios`)
+  - livekit_client (from `.symlinks/plugins/livekit_client/ios`)
+  - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
   - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
   - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
   - record_ios (from `.symlinks/plugins/record_ios/ios`)
   - record_ios (from `.symlinks/plugins/record_ios/ios`)
   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
   - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
 
 
+SPEC REPOS:
+  trunk:
+    - WebRTC-SDK
+
 EXTERNAL SOURCES:
 EXTERNAL SOURCES:
+  connectivity_plus:
+    :path: ".symlinks/plugins/connectivity_plus/ios"
+  device_info_plus:
+    :path: ".symlinks/plugins/device_info_plus/ios"
   Flutter:
   Flutter:
     :path: Flutter
     :path: Flutter
+  flutter_webrtc:
+    :path: ".symlinks/plugins/flutter_webrtc/ios"
   integration_test:
   integration_test:
     :path: ".symlinks/plugins/integration_test/ios"
     :path: ".symlinks/plugins/integration_test/ios"
+  livekit_client:
+    :path: ".symlinks/plugins/livekit_client/ios"
+  path_provider_foundation:
+    :path: ".symlinks/plugins/path_provider_foundation/darwin"
   permission_handler_apple:
   permission_handler_apple:
     :path: ".symlinks/plugins/permission_handler_apple/ios"
     :path: ".symlinks/plugins/permission_handler_apple/ios"
   record_ios:
   record_ios:
@@ -30,11 +64,17 @@ EXTERNAL SOURCES:
     :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
     :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
 
 
 SPEC CHECKSUMS:
 SPEC CHECKSUMS:
+  connectivity_plus: 2a701ffec2c0ae28a48cf7540e279787e77c447d
+  device_info_plus: bf2e3232933866d73fe290f2942f2156cdd10342
   Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
   Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
+  flutter_webrtc: 5aa37bfb8b834bff989a03182d1c4266a4f93377
   integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
   integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
+  livekit_client: 88462cc0b8d19febb57679476bae8696961cfca2
+  path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
   permission_handler_apple: ee2fe0fd04551b304eb002714ff067c371c822ed
   permission_handler_apple: ee2fe0fd04551b304eb002714ff067c371c822ed
   record_ios: 3b171007ae5de51221a3a76d2b379a7105ca98b5
   record_ios: 3b171007ae5de51221a3a76d2b379a7105ca98b5
   shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
   shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
+  WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
 
 
 PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce
 PODFILE CHECKSUM: 251cb053df7158f337c0712f2ab29f4e0fa474ce
 
 

+ 1 - 4
flutter_asr_client/lib/common/constants.dart

@@ -1,10 +1,6 @@
 import 'dart:io';
 import 'dart:io';
 
 
 abstract final class AppConstants {
 abstract final class AppConstants {
-  // On the Android emulator `localhost` points at the emulator itself, not the
-  // dev machine. `10.0.2.2` is the emulator's alias for the host loopback, so
-  // it is the correct default there. Physical devices must set the host
-  // explicitly to the dev machine's LAN IP via the server settings dialog.
   static String get defaultHost =>
   static String get defaultHost =>
       Platform.isAndroid ? '192.168.199.195' : '127.0.0.1';
       Platform.isAndroid ? '192.168.199.195' : '127.0.0.1';
   static const defaultPort = 8765;
   static const defaultPort = 8765;
@@ -17,4 +13,5 @@ abstract final class AppConstants {
   static const reconnectBaseDelay = Duration(seconds: 1);
   static const reconnectBaseDelay = Duration(seconds: 1);
   static const reconnectMaxDelay = Duration(seconds: 30);
   static const reconnectMaxDelay = Duration(seconds: 30);
   static const appName = '检测数据采集';
   static const appName = '检测数据采集';
+  static const defaultLiveKitUrl = 'ws://localhost:7888';
 }
 }

+ 10 - 1
flutter_asr_client/lib/main.dart

@@ -1,8 +1,17 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:asr_client/app.dart';
 import 'package:asr_client/app.dart';
+import 'package:asr_client/providers/livekit_providers.dart';
+import 'package:asr_client/services/livekit_service.dart';
 
 
 void main() {
 void main() {
   WidgetsFlutterBinding.ensureInitialized();
   WidgetsFlutterBinding.ensureInitialized();
-  runApp(const ProviderScope(child: AsrApp()));
+  runApp(
+    ProviderScope(
+      overrides: [
+        liveKitServiceProvider.overrideWithValue(LiveKitServiceImpl()),
+      ],
+      child: const AsrApp(),
+    ),
+  );
 }
 }

+ 20 - 0
flutter_asr_client/lib/models/websocket_message.dart

@@ -58,6 +58,14 @@ sealed class ServerMessage {
           text: json['text'] as String? ?? '',
           text: json['text'] as String? ?? '',
           seq: json['seq'] as int? ?? 0,
           seq: json['seq'] as int? ?? 0,
         );
         );
+      case 'reply':
+        return ReplyMessage(
+          text: json['text'] as String? ?? '',
+        );
+      case 'reply_partial':
+        return ReplyPartialMessage(
+          text: json['text'] as String? ?? '',
+        );
       case 'cleared':
       case 'cleared':
         return const ClearedMessage();
         return const ClearedMessage();
       case 'error':
       case 'error':
@@ -99,6 +107,18 @@ final class UtteranceMessage extends ServerMessage {
   final int seq;
   final int seq;
 }
 }
 
 
+final class ReplyMessage extends ServerMessage {
+  const ReplyMessage({required this.text});
+
+  final String text;
+}
+
+final class ReplyPartialMessage extends ServerMessage {
+  const ReplyPartialMessage({required this.text});
+
+  final String text;
+}
+
 final class ClearedMessage extends ServerMessage {
 final class ClearedMessage extends ServerMessage {
   const ClearedMessage();
   const ClearedMessage();
 }
 }

+ 45 - 24
flutter_asr_client/lib/pages/profile/widgets/server_settings_dialog.dart

@@ -15,6 +15,7 @@ class ServerSettingsDialog extends ConsumerStatefulWidget {
 class _ServerSettingsDialogState extends ConsumerState<ServerSettingsDialog> {
 class _ServerSettingsDialogState extends ConsumerState<ServerSettingsDialog> {
   late final TextEditingController _hostController;
   late final TextEditingController _hostController;
   late final TextEditingController _portController;
   late final TextEditingController _portController;
+  late final TextEditingController _liveKitController;
 
 
   @override
   @override
   void initState() {
   void initState() {
@@ -23,14 +24,18 @@ class _ServerSettingsDialogState extends ConsumerState<ServerSettingsDialog> {
         ref.read(serverHostProvider).valueOrNull ?? AppConstants.defaultHost;
         ref.read(serverHostProvider).valueOrNull ?? AppConstants.defaultHost;
     final port =
     final port =
         ref.read(serverPortProvider).valueOrNull ?? AppConstants.defaultPort;
         ref.read(serverPortProvider).valueOrNull ?? AppConstants.defaultPort;
+    final lkUrl = ref.read(liveKitUrlProvider).valueOrNull ??
+        AppConstants.defaultLiveKitUrl;
     _hostController = TextEditingController(text: host);
     _hostController = TextEditingController(text: host);
     _portController = TextEditingController(text: port.toString());
     _portController = TextEditingController(text: port.toString());
+    _liveKitController = TextEditingController(text: lkUrl);
   }
   }
 
 
   @override
   @override
   void dispose() {
   void dispose() {
     _hostController.dispose();
     _hostController.dispose();
     _portController.dispose();
     _portController.dispose();
+    _liveKitController.dispose();
     super.dispose();
     super.dispose();
   }
   }
 
 
@@ -40,28 +45,39 @@ class _ServerSettingsDialogState extends ConsumerState<ServerSettingsDialog> {
       backgroundColor: AppColors.card,
       backgroundColor: AppColors.card,
       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
       title: const Text('服务器设置'),
       title: const Text('服务器设置'),
-      content: Column(
-        mainAxisSize: MainAxisSize.min,
-        children: [
-          TextField(
-            controller: _hostController,
-            decoration: const InputDecoration(
-              labelText: '主机地址',
-              hintText: '例如 localhost',
-              border: OutlineInputBorder(),
+      content: SingleChildScrollView(
+        child: Column(
+          mainAxisSize: MainAxisSize.min,
+          children: [
+            TextField(
+              controller: _liveKitController,
+              decoration: const InputDecoration(
+                labelText: 'LiveKit 地址',
+                hintText: 'ws://36.152.142.37:10003',
+                border: OutlineInputBorder(),
+              ),
             ),
             ),
-          ),
-          const SizedBox(height: 12),
-          TextField(
-            controller: _portController,
-            keyboardType: TextInputType.number,
-            decoration: const InputDecoration(
-              labelText: '端口',
-              hintText: '例如 8765',
-              border: OutlineInputBorder(),
+            const SizedBox(height: 12),
+            TextField(
+              controller: _hostController,
+              decoration: const InputDecoration(
+                labelText: '主机地址',
+                hintText: '例如 localhost',
+                border: OutlineInputBorder(),
+              ),
             ),
             ),
-          ),
-        ],
+            const SizedBox(height: 12),
+            TextField(
+              controller: _portController,
+              keyboardType: TextInputType.number,
+              decoration: const InputDecoration(
+                labelText: '端口',
+                hintText: '例如 8765',
+                border: OutlineInputBorder(),
+              ),
+            ),
+          ],
+        ),
       ),
       ),
       actions: [
       actions: [
         TextButton(
         TextButton(
@@ -77,12 +93,17 @@ class _ServerSettingsDialogState extends ConsumerState<ServerSettingsDialog> {
     final host = _hostController.text.trim();
     final host = _hostController.text.trim();
     final port =
     final port =
         int.tryParse(_portController.text.trim()) ?? AppConstants.defaultPort;
         int.tryParse(_portController.text.trim()) ?? AppConstants.defaultPort;
-
-    if (host.isEmpty) return;
+    final lkUrl = _liveKitController.text.trim();
 
 
     final service = ref.read(settingsServiceProvider);
     final service = ref.read(settingsServiceProvider);
-    await service.setHost(host);
-    await service.setPort(port);
+    if (host.isNotEmpty) {
+      await service.setHost(host);
+      await service.setPort(port);
+    }
+    if (lkUrl.isNotEmpty) {
+      await service.setLiveKitUrl(lkUrl);
+    }
+    ref.invalidate(liveKitUrlProvider);
 
 
     if (mounted) Navigator.of(context).pop();
     if (mounted) Navigator.of(context).pop();
   }
   }

+ 76 - 165
flutter_asr_client/lib/pages/recording/notifiers/recording_notifier.dart

@@ -4,22 +4,19 @@ import 'package:flutter/foundation.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:flutter_riverpod/flutter_riverpod.dart';
 import 'package:uuid/uuid.dart';
 import 'package:uuid/uuid.dart';
 
 
-import 'package:asr_client/common/constants.dart';
 import 'package:asr_client/models/conversation.dart';
 import 'package:asr_client/models/conversation.dart';
 import 'package:asr_client/models/websocket_message.dart';
 import 'package:asr_client/models/websocket_message.dart';
-import 'package:asr_client/providers/audio_providers.dart';
+import 'package:asr_client/providers/livekit_providers.dart';
+import 'package:asr_client/services/livekit_service.dart';
 import 'package:asr_client/providers/settings_providers.dart';
 import 'package:asr_client/providers/settings_providers.dart';
-import 'package:asr_client/providers/websocket_providers.dart';
-import 'package:asr_client/services/audio_capture_service.dart';
-import 'package:asr_client/services/websocket_service.dart';
 
 
 enum RecordingStatus { idle, recording, paused, ending }
 enum RecordingStatus { idle, recording, paused, ending }
 
 
 @immutable
 @immutable
-const _unset = _Unset();
 final class _Unset {
 final class _Unset {
   const _Unset();
   const _Unset();
 }
 }
+const _unset = _Unset();
 
 
 final class RecordingState {
 final class RecordingState {
   const RecordingState({
   const RecordingState({
@@ -28,6 +25,7 @@ final class RecordingState {
     this.audioLevel = 0.0,
     this.audioLevel = 0.0,
     this.items = const [],
     this.items = const [],
     this.liveUtterance,
     this.liveUtterance,
+    this.liveReply,
     this.isConnected = false,
     this.isConnected = false,
     this.errorMessage,
     this.errorMessage,
     this.sessionId,
     this.sessionId,
@@ -41,6 +39,7 @@ final class RecordingState {
   final double audioLevel;
   final double audioLevel;
   final List<ConversationItem> items;
   final List<ConversationItem> items;
   final String? liveUtterance;
   final String? liveUtterance;
+  final String? liveReply;
   final bool isConnected;
   final bool isConnected;
   final String? errorMessage;
   final String? errorMessage;
   final String? sessionId;
   final String? sessionId;
@@ -54,6 +53,7 @@ final class RecordingState {
     double? audioLevel,
     double? audioLevel,
     List<ConversationItem>? items,
     List<ConversationItem>? items,
     Object? liveUtterance = _unset,
     Object? liveUtterance = _unset,
+    Object? liveReply = _unset,
     bool? isConnected,
     bool? isConnected,
     Object? errorMessage = _unset,
     Object? errorMessage = _unset,
     Object? sessionId = _unset,
     Object? sessionId = _unset,
@@ -69,15 +69,22 @@ final class RecordingState {
       liveUtterance: identical(liveUtterance, _unset)
       liveUtterance: identical(liveUtterance, _unset)
           ? this.liveUtterance
           ? this.liveUtterance
           : liveUtterance as String?,
           : liveUtterance as String?,
+      liveReply: identical(liveReply, _unset)
+          ? this.liveReply
+          : liveReply as String?,
       isConnected: isConnected ?? this.isConnected,
       isConnected: isConnected ?? this.isConnected,
-      errorMessage:
-          identical(errorMessage, _unset) ? this.errorMessage : errorMessage as String?,
-      sessionId:
-          identical(sessionId, _unset) ? this.sessionId : sessionId as String?,
+      errorMessage: identical(errorMessage, _unset)
+          ? this.errorMessage
+          : errorMessage as String?,
+      sessionId: identical(sessionId, _unset)
+          ? this.sessionId
+          : sessionId as String?,
       projectName: identical(projectName, _unset)
       projectName: identical(projectName, _unset)
           ? this.projectName
           ? this.projectName
           : projectName as String?,
           : projectName as String?,
-      taskName: identical(taskName, _unset) ? this.taskName : taskName as String?,
+      taskName: identical(taskName, _unset)
+          ? this.taskName
+          : taskName as String?,
       templateName: identical(templateName, _unset)
       templateName: identical(templateName, _unset)
           ? this.templateName
           ? this.templateName
           : templateName as String?,
           : templateName as String?,
@@ -95,165 +102,71 @@ final recordingNotifierProvider =
 
 
 final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
 final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
   Timer? _timer;
   Timer? _timer;
-  Timer? _transcribeTimer;
-  Timer? _audioSendTimer;
   final _uuid = const Uuid();
   final _uuid = const Uuid();
   DateTime? _startTime;
   DateTime? _startTime;
   Duration _accumulated = Duration.zero;
   Duration _accumulated = Duration.zero;
-  var _isSendingAudio = false;
+  StreamSubscription<ServerMessage>? _messageSub;
 
 
-  WebSocketService get _webSocketService => ref.read(webSocketServiceProvider);
-
-  AudioCaptureService get _audioCaptureService =>
-      ref.read(audioCaptureServiceProvider);
+  LiveKitService get _liveKit => ref.read(liveKitServiceProvider);
 
 
   @override
   @override
   FutureOr<RecordingState> build() async {
   FutureOr<RecordingState> build() async {
     ref.onDispose(() {
     ref.onDispose(() {
       _timer?.cancel();
       _timer?.cancel();
-      _transcribeTimer?.cancel();
-      _audioSendTimer?.cancel();
+      _messageSub?.cancel();
     });
     });
 
 
+    final roomName = 'asr-test';
+    final identity = 'user-${_uuid.v4().substring(0, 6)}';
+    final lkUrl = await ref.read(settingsServiceProvider).getLiveKitUrl();
+
     final initialState = const RecordingState(
     final initialState = const RecordingState(
-      sessionId: 'LC-20260707-03',
       projectName: 'G15 沈海高速改扩建 · K1120+300',
       projectName: 'G15 沈海高速改扩建 · K1120+300',
       taskName: '混凝土坍落度试验',
       taskName: '混凝土坍落度试验',
       templateName: '模板 · 普通混凝土坍落度 GB/T 50080',
       templateName: '模板 · 普通混凝土坍落度 GB/T 50080',
     );
     );
 
 
     try {
     try {
-      await _connectWebSocket();
+      await _liveKit.connect(url: lkUrl, room: roomName, identity: identity);
     } on Exception catch (e) {
     } on Exception catch (e) {
-      state = AsyncData(
-        initialState.copyWith(
-          status: RecordingStatus.idle,
-          errorMessage: '无法连接服务器: $e',
-        ),
+      return initialState.copyWith(
+        errorMessage: '无法连接 LiveKit: $e',
       );
       );
-      return state.value ?? initialState;
-    }
-    if (_webSocketService.currentState != ConnectionState.connected) {
-      state = AsyncData(
-        initialState.copyWith(
-          status: RecordingStatus.idle,
-          errorMessage: '无法连接服务器,请检查服务端地址与网络后重试',
-        ),
-      );
-      return state.value ?? initialState;
-    }
-    await _startRecording(initialState);
-    // Also set isConnected from the actual WebSocket state, because the
-    // server's 'connected' message may have arrived before the listener was
-    // attached (broadcast stream doesn't buffer).
-    if (_webSocketService.currentState == ConnectionState.connected) {
-      _updateState((s) => s.copyWith(isConnected: true));
     }
     }
-    return state.value ?? initialState;
-  }
 
 
-  Future<void> _connectWebSocket() async {
-    final url = await ref.read(serverUrlProvider.future);
-    await _webSocketService.connect(url);
+    _listenToMessages();
+    return _startedState(initialState);
   }
   }
 
 
-  Future<void> _startRecording(RecordingState initialState) async {
-    try {
-      final hasPermission = await _audioCaptureService.requestPermission();
-      if (!hasPermission) {
-        final permanentlyDenied =
-            await _audioCaptureService.isPermissionPermanentlyDenied;
-        if (permanentlyDenied) {
-          await _audioCaptureService.openSettings();
-        }
-        state = AsyncData(
-          initialState.copyWith(
-            status: RecordingStatus.idle,
-            errorMessage: permanentlyDenied
-                ? '麦克风权限被拒绝,请在系统设置中开启后重试'
-                : '需要麦克风权限才能进行录音',
-          ),
-        );
-        return;
-      }
-
-      await _audioCaptureService.startRecording();
-      state = AsyncData(
-        initialState.copyWith(status: RecordingStatus.recording),
-      );
-      _startTimer();
-      _startTranscribeTimer();
-      _startAudioSendTimer();
-      _listenToAudioLevel();
-      _listenToWebSocketMessages();
-    } on Exception catch (e) {
-      state = AsyncData(
-        initialState.copyWith(
-          status: RecordingStatus.idle,
-          errorMessage: '录音启动失败: $e',
-        ),
-      );
-    }
-  }
-
-  void _startTimer() {
+  RecordingState _startedState(RecordingState s) {
     _startTime = DateTime.now();
     _startTime = DateTime.now();
-    _timer?.cancel();
     _timer = Timer.periodic(const Duration(seconds: 1), (_) {
     _timer = Timer.periodic(const Duration(seconds: 1), (_) {
       final now = DateTime.now();
       final now = DateTime.now();
-      final elapsed =
-          _accumulated +
+      final elapsed = _accumulated +
           (_startTime != null ? now.difference(_startTime!) : Duration.zero);
           (_startTime != null ? now.difference(_startTime!) : Duration.zero);
-      _updateState((s) => s.copyWith(elapsed: elapsed));
-    });
-  }
-
-  void _startTranscribeTimer() {
-    _transcribeTimer?.cancel();
-    _transcribeTimer = Timer.periodic(AppConstants.transcribeInterval, (_) {
-      if (state.value?.isRecording ?? false) {
-        _webSocketService.send(const TranscribeMessage());
-      }
+      _updateState((current) => current.copyWith(elapsed: elapsed));
     });
     });
+    return s.copyWith(status: RecordingStatus.recording, isConnected: true);
   }
   }
 
 
-  void _startAudioSendTimer() {
-    _audioSendTimer?.cancel();
-    _audioSendTimer = Timer.periodic(AppConstants.audioSendInterval, (_) {
-      if (!(state.value?.isRecording ?? false) || _isSendingAudio) return;
-      _isSendingAudio = true;
-      try {
-        final bytes = _audioCaptureService.takeBuffer();
-        if (bytes != null && bytes.isNotEmpty) {
-          _webSocketService.sendBinary(bytes);
-        }
-      } on Exception catch (_) {
-      } finally {
-        _isSendingAudio = false;
-      }
-    });
-  }
-
-  void _listenToAudioLevel() {
-    _audioCaptureService.audioLevelStream.listen(
-      (level) => _updateState((s) => s.copyWith(audioLevel: level)),
-      onError: (_) {},
-    );
-  }
-
-  void _listenToWebSocketMessages() {
-    _webSocketService.messageStream.listen((message) {
+  void _listenToMessages() {
+    _messageSub = _liveKit.messageStream.listen((message) {
+      debugPrint('[notifier] msg: ${message.runtimeType}');
       switch (message) {
       switch (message) {
-        case ConnectedMessage():
-          _updateState((s) => s.copyWith(isConnected: true));
         case PartialMessage(:final text):
         case PartialMessage(:final text):
           _updateState((s) => s.copyWith(liveUtterance: text));
           _updateState((s) => s.copyWith(liveUtterance: text));
         case UtteranceMessage(:final text):
         case UtteranceMessage(:final text):
           _finalizeBubble(text);
           _finalizeBubble(text);
+        case ReplyMessage(:final text):
+          _addAiReply(text);
+        case ReplyPartialMessage(:final text):
+          _updateState((s) => s.copyWith(liveReply: text));
         case ErrorMessage(:final message):
         case ErrorMessage(:final message):
           _updateState((s) => s.copyWith(errorMessage: message));
           _updateState((s) => s.copyWith(errorMessage: message));
         case ClearedMessage():
         case ClearedMessage():
           break;
           break;
+        case ConnectedMessage():
+          _updateState((s) => s.copyWith(isConnected: true));
       }
       }
     }, onError: (_) {});
     }, onError: (_) {});
   }
   }
@@ -272,15 +185,26 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
     ));
     ));
   }
   }
 
 
+  void _addAiReply(String text) {
+    final item = ConversationItem(
+      id: _uuid.v4(),
+      type: ConversationItemType.aiCard,
+      timestamp: DateTime.now(),
+      text: text,
+    );
+    _updateState((s) => s.copyWith(items: [...s.items, item]));
+  }
+
   void pauseRecording() {
   void pauseRecording() {
     if (state.value?.status != RecordingStatus.recording) return;
     if (state.value?.status != RecordingStatus.recording) return;
     _accumulated = state.value!.elapsed;
     _accumulated = state.value!.elapsed;
     _timer?.cancel();
     _timer?.cancel();
-    _transcribeTimer?.cancel();
-    _audioSendTimer?.cancel();
-    _audioCaptureService.stopRecording();
-    _webSocketService.send(const FinalizeMessage());
-    // Let any final utterance message from server arrive before clearing UI.
+    // Finalize any pending live utterance before disconnecting.
+    final live = state.value?.liveUtterance?.trim();
+    if (live != null && live.isNotEmpty) {
+      _finalizeBubble(live);
+    }
+    _liveKit.disconnect();
     _updateState((s) => s.copyWith(
     _updateState((s) => s.copyWith(
       status: RecordingStatus.paused,
       status: RecordingStatus.paused,
       liveUtterance: null,
       liveUtterance: null,
@@ -290,18 +214,12 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
   Future<void> resumeRecording() async {
   Future<void> resumeRecording() async {
     if (state.value?.status != RecordingStatus.paused) return;
     if (state.value?.status != RecordingStatus.paused) return;
     try {
     try {
-      await _audioCaptureService.startRecording();
-      _startTimer();
-      _startTranscribeTimer();
-      _startAudioSendTimer();
-      _updateState((s) => s.copyWith(status: RecordingStatus.recording));
+      final roomName = 'asr-test';
+      final lkUrl = await ref.read(settingsServiceProvider).getLiveKitUrl();
+      await _liveKit.connect(url: lkUrl, room: roomName, identity: 'user-${_uuid.v4().substring(0, 6)}');
+      _updateState((s) => _startedState(s));
     } on Exception catch (e) {
     } on Exception catch (e) {
-      _updateState(
-        (s) => s.copyWith(
-          status: RecordingStatus.paused,
-          errorMessage: '继续录音失败: $e',
-        ),
-      );
+      _updateState((s) => s.copyWith(errorMessage: '无法恢复录音: $e'));
     }
     }
   }
   }
 
 
@@ -311,28 +229,21 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
 
 
   Future<void> confirmEnd() async {
   Future<void> confirmEnd() async {
     _timer?.cancel();
     _timer?.cancel();
-    _transcribeTimer?.cancel();
-    _audioSendTimer?.cancel();
-    _webSocketService.send(const FinalizeMessage());
-    await _audioCaptureService.stopRecording();
-    _webSocketService.disconnect();
-    _updateState(
-      (s) => s.copyWith(
-        status: RecordingStatus.idle,
-        elapsed: Duration.zero,
-        items: [],
-        liveUtterance: null,
-        isConnected: false,
-      ),
-    );
+    _messageSub?.cancel();
+    await _liveKit.disconnect();
+    _updateState((s) => s.copyWith(
+      status: RecordingStatus.idle,
+      elapsed: Duration.zero,
+      items: [],
+      liveUtterance: null,
+      isConnected: false,
+    ));
   }
   }
 
 
   void cancelEnd() {
   void cancelEnd() {
-    _updateState(
-      (s) => s.copyWith(
-        status: s.isPaused ? RecordingStatus.paused : RecordingStatus.recording,
-      ),
-    );
+    _updateState((s) => s.copyWith(
+      status: s.isPaused ? RecordingStatus.paused : RecordingStatus.recording,
+    ));
   }
   }
 
 
   void clearError() {
   void clearError() {

+ 48 - 1
flutter_asr_client/lib/pages/recording/widgets/conversation_list.dart

@@ -22,7 +22,9 @@ class ConversationList extends StatelessWidget {
         for (final item in items)
         for (final item in items)
           Padding(
           Padding(
             padding: const EdgeInsets.only(bottom: 10),
             padding: const EdgeInsets.only(bottom: 10),
-            child: UserBubble(key: ValueKey(item.id), item: item),
+            child: item.type == ConversationItemType.aiCard
+                ? _AiBubble(key: ValueKey(item.id), text: item.text ?? '')
+                : UserBubble(key: ValueKey(item.id), item: item),
           ),
           ),
         if (liveUtterance != null && liveUtterance!.trim().isNotEmpty)
         if (liveUtterance != null && liveUtterance!.trim().isNotEmpty)
           _LiveBubble(text: liveUtterance!, isRecording: isRecording),
           _LiveBubble(text: liveUtterance!, isRecording: isRecording),
@@ -125,3 +127,48 @@ class _PulsingDotsState extends State<_PulsingDots>
     );
     );
   }
   }
 }
 }
+
+class _AiBubble extends StatelessWidget {
+  const _AiBubble({super.key, required this.text});
+
+  final String text;
+
+  @override
+  Widget build(BuildContext context) {
+    return Align(
+      alignment: Alignment.centerLeft,
+      child: ConstrainedBox(
+        constraints: BoxConstraints(
+          maxWidth: MediaQuery.of(context).size.width * 0.78,
+        ),
+        child: Container(
+          padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
+          decoration: BoxDecoration(
+            color: AppColors.card,
+            borderRadius: const BorderRadius.only(
+              topLeft: Radius.circular(4),
+              topRight: Radius.circular(16),
+              bottomLeft: Radius.circular(16),
+              bottomRight: Radius.circular(16),
+            ),
+            border: Border.all(color: AppColors.line),
+          ),
+          child: Column(
+            crossAxisAlignment: CrossAxisAlignment.start,
+            children: [
+              const Text(
+                'AI',
+                style: TextStyle(fontSize: 10, color: AppColors.app, fontWeight: FontWeight.w700),
+              ),
+              const SizedBox(height: 4),
+              Text(
+                text,
+                style: const TextStyle(fontSize: 13.5, color: AppColors.ink, height: 1.4),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 18 - 0
flutter_asr_client/lib/providers/livekit_providers.dart

@@ -0,0 +1,18 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:uuid/uuid.dart';
+
+import 'package:asr_client/models/websocket_message.dart';
+import 'package:asr_client/services/livekit_service.dart';
+
+final liveKitServiceProvider = Provider<LiveKitService>((ref) {
+  throw UnimplementedError('Override in app bootstrap');
+});
+
+final liveKitRoomNameProvider = Provider<String>((ref) {
+  return 'asr-${const Uuid().v4().substring(0, 8)}';
+});
+
+final liveKitMessageStreamProvider = StreamProvider<ServerMessage>((ref) {
+  final service = ref.watch(liveKitServiceProvider);
+  return service.messageStream;
+});

+ 5 - 0
flutter_asr_client/lib/providers/settings_providers.dart

@@ -21,3 +21,8 @@ final serverUrlProvider = FutureProvider<String>((ref) async {
   final port = await service.getPort();
   final port = await service.getPort();
   return service.getWebSocketUrl(host, port);
   return service.getWebSocketUrl(host, port);
 });
 });
+
+final liveKitUrlProvider = FutureProvider<String>((ref) async {
+  final service = ref.watch(settingsServiceProvider);
+  return service.getLiveKitUrl();
+});

+ 131 - 0
flutter_asr_client/lib/services/livekit_service.dart

@@ -0,0 +1,131 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
+import 'package:flutter/foundation.dart';
+import 'package:livekit_client/livekit_client.dart';
+import 'package:asr_client/models/websocket_message.dart';
+
+/// Wraps LiveKit Room connection and relays transcription data messages.
+abstract interface class LiveKitService {
+  Stream<ServerMessage> get messageStream;
+  bool get isConnected;
+
+  Future<void> connect({required String url, required String room, required String identity});
+  Future<void> disconnect();
+}
+
+final class LiveKitServiceImpl implements LiveKitService {
+  LiveKitServiceImpl({Room? room}) : _room = room ?? Room();
+
+  final Room _room;
+  final _messageController = StreamController<ServerMessage>.broadcast();
+  var _connected = false;
+  EventsListener<RoomEvent>? _listener;
+
+  @override
+  Stream<ServerMessage> get messageStream => _messageController.stream;
+
+  @override
+  bool get isConnected => _connected;
+
+  @override
+  Future<void> connect({
+    required String url,
+    required String room,
+    required String identity,
+  }) async {
+    if (_connected) return;
+
+    final token = _makeToken(room, identity);
+    debugPrint('[lk] connecting to $url room=$room');
+
+    // Listen for room events (including connection failures)
+    _listener = _room.createListener()
+      ..on<DataReceivedEvent>((e) {
+        debugPrint('[lk] data received topic=${e.topic} bytes=${e.data.length}');
+        if (e.topic == 'transcription') {
+          try {
+            final json = jsonDecode(utf8.decode(e.data)) as Map<String, dynamic>;
+            final message = ServerMessage.fromJson(json);
+            _messageController.add(message);
+          } catch (ex) {
+            debugPrint('[lk] bad data: $ex');
+          }
+        }
+      })
+      ..on<RoomDisconnectedEvent>((e) {
+        debugPrint('[lk] disconnected: ${e.reason}');
+        _connected = false;
+      })
+      ..on<RoomReconnectingEvent>((e) {
+        debugPrint('[lk] reconnecting');
+      })
+      ..on<RoomReconnectedEvent>((e) {
+        debugPrint('[lk] reconnected');
+      });
+
+    try {
+      await _room.connect(
+        '$url',
+        token,
+      );
+    } catch (e, st) {
+      debugPrint('[lk] connect failed: $e');
+      debugPrint('[lk] stack: $st');
+      rethrow;
+    }
+
+    debugPrint('[lk] connected, publishing mic...');
+
+    try {
+      await _room.localParticipant!.setMicrophoneEnabled(true);
+      debugPrint('[lk] mic published');
+    } catch (e, st) {
+      debugPrint('[lk] mic FAILED: $e\n$st');
+    }
+
+    _connected = true;
+    _messageController.add(const ConnectedMessage(model: {}));
+  }
+
+  @override
+  Future<void> disconnect() async {
+    if (!_connected) return;
+    _connected = false;
+    _listener?.dispose();
+    _listener = null;
+    await _room.disconnect();
+  }
+
+  String _makeToken(String room, String identity) {
+    // Dev token: for localhost LiveKit Server with key "devkey"
+    // In production, tokens should be generated server-side.
+    const key = 'devkey';
+    const secret = 'secretsecretsecretsecretsecret12';
+
+    final jwt = JWT(
+      {
+        'iss': key,
+        'sub': identity,
+        'name': identity,
+        'nbf': DateTime.now()
+            .subtract(const Duration(minutes: 1))
+            .millisecondsSinceEpoch ~/
+            1000,
+        'exp': DateTime.now()
+            .add(const Duration(hours: 6))
+            .millisecondsSinceEpoch ~/
+            1000,
+        'video': {
+          'roomJoin': true,
+          'room': room,
+          'canPublish': true,
+          'canSubscribe': true,
+          'canPublishData': true,
+        },
+      },
+    );
+    return jwt.sign(SecretKey(secret));
+  }
+}

+ 15 - 0
flutter_asr_client/lib/services/settings_service.dart

@@ -9,6 +9,8 @@ abstract interface class SettingsService {
   Future<void> setHost(String host);
   Future<void> setHost(String host);
   Future<void> setPort(int port);
   Future<void> setPort(int port);
   String getWebSocketUrl(String host, int port);
   String getWebSocketUrl(String host, int port);
+  Future<String> getLiveKitUrl();
+  Future<void> setLiveKitUrl(String url);
 }
 }
 
 
 final class SharedPreferencesSettingsService implements SettingsService {
 final class SharedPreferencesSettingsService implements SettingsService {
@@ -57,6 +59,19 @@ final class SharedPreferencesSettingsService implements SettingsService {
   @override
   @override
   String getWebSocketUrl(String host, int port) => 'ws://$host:$port';
   String getWebSocketUrl(String host, int port) => 'ws://$host:$port';
 
 
+  @override
+  Future<String> getLiveKitUrl() async {
+    final prefs = await _preferences;
+    return prefs.getString(_lkUrlKey) ?? AppConstants.defaultLiveKitUrl;
+  }
+
+  @override
+  Future<void> setLiveKitUrl(String url) async {
+    final prefs = await _preferences;
+    await prefs.setString(_lkUrlKey, url.trim());
+  }
+
   static const _hostKey = 'asr_host';
   static const _hostKey = 'asr_host';
   static const _portKey = 'asr_port';
   static const _portKey = 'asr_port';
+  static const _lkUrlKey = 'asr_livekit_url';
 }
 }

+ 8 - 0
flutter_asr_client/linux/flutter/generated_plugin_registrant.cc

@@ -6,9 +6,17 @@
 
 
 #include "generated_plugin_registrant.h"
 #include "generated_plugin_registrant.h"
 
 
+#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
+#include <livekit_client/live_kit_plugin.h>
 #include <record_linux/record_linux_plugin.h>
 #include <record_linux/record_linux_plugin.h>
 
 
 void fl_register_plugins(FlPluginRegistry* registry) {
 void fl_register_plugins(FlPluginRegistry* registry) {
+  g_autoptr(FlPluginRegistrar) flutter_webrtc_registrar =
+      fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterWebRTCPlugin");
+  flutter_web_r_t_c_plugin_register_with_registrar(flutter_webrtc_registrar);
+  g_autoptr(FlPluginRegistrar) livekit_client_registrar =
+      fl_plugin_registry_get_registrar_for_plugin(registry, "LiveKitPlugin");
+  live_kit_plugin_register_with_registrar(livekit_client_registrar);
   g_autoptr(FlPluginRegistrar) record_linux_registrar =
   g_autoptr(FlPluginRegistrar) record_linux_registrar =
       fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
       fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin");
   record_linux_plugin_register_with_registrar(record_linux_registrar);
   record_linux_plugin_register_with_registrar(record_linux_registrar);

+ 3 - 0
flutter_asr_client/linux/flutter/generated_plugins.cmake

@@ -3,10 +3,13 @@
 #
 #
 
 
 list(APPEND FLUTTER_PLUGIN_LIST
 list(APPEND FLUTTER_PLUGIN_LIST
+  flutter_webrtc
+  livekit_client
   record_linux
   record_linux
 )
 )
 
 
 list(APPEND FLUTTER_FFI_PLUGIN_LIST
 list(APPEND FLUTTER_FFI_PLUGIN_LIST
+  jni
 )
 )
 
 
 set(PLUGIN_BUNDLED_LIBRARIES)
 set(PLUGIN_BUNDLED_LIBRARIES)

+ 10 - 0
flutter_asr_client/macos/Flutter/GeneratedPluginRegistrant.swift

@@ -5,10 +5,20 @@
 import FlutterMacOS
 import FlutterMacOS
 import Foundation
 import Foundation
 
 
+import connectivity_plus
+import device_info_plus
+import flutter_webrtc
+import livekit_client
+import path_provider_foundation
 import record_macos
 import record_macos
 import shared_preferences_foundation
 import shared_preferences_foundation
 
 
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
+  ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
+  DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
+  FlutterWebRTCPlugin.register(with: registry.registrar(forPlugin: "FlutterWebRTCPlugin"))
+  LiveKitPlugin.register(with: registry.registrar(forPlugin: "LiveKitPlugin"))
+  PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
   RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
   RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
   SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
   SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
 }
 }

+ 40 - 0
flutter_asr_client/macos/Podfile.lock

@@ -1,28 +1,68 @@
 PODS:
 PODS:
+  - connectivity_plus (0.0.1):
+    - FlutterMacOS
+  - device_info_plus (0.0.1):
+    - FlutterMacOS
+  - flutter_webrtc (1.4.0):
+    - FlutterMacOS
+    - WebRTC-SDK (= 144.7559.01)
   - FlutterMacOS (1.0.0)
   - FlutterMacOS (1.0.0)
+  - livekit_client (2.8.1):
+    - flutter_webrtc
+    - FlutterMacOS
+    - WebRTC-SDK (= 144.7559.01)
+  - path_provider_foundation (0.0.1):
+    - Flutter
+    - FlutterMacOS
   - record_macos (1.2.1):
   - record_macos (1.2.1):
     - FlutterMacOS
     - FlutterMacOS
   - shared_preferences_foundation (0.0.1):
   - shared_preferences_foundation (0.0.1):
     - Flutter
     - Flutter
     - FlutterMacOS
     - FlutterMacOS
+  - WebRTC-SDK (144.7559.01)
 
 
 DEPENDENCIES:
 DEPENDENCIES:
+  - connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
+  - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
+  - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`)
   - FlutterMacOS (from `Flutter/ephemeral`)
   - FlutterMacOS (from `Flutter/ephemeral`)
+  - livekit_client (from `Flutter/ephemeral/.symlinks/plugins/livekit_client/macos`)
+  - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
   - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
   - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`)
   - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
   - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
 
 
+SPEC REPOS:
+  trunk:
+    - WebRTC-SDK
+
 EXTERNAL SOURCES:
 EXTERNAL SOURCES:
+  connectivity_plus:
+    :path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
+  device_info_plus:
+    :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
+  flutter_webrtc:
+    :path: Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos
   FlutterMacOS:
   FlutterMacOS:
     :path: Flutter/ephemeral
     :path: Flutter/ephemeral
+  livekit_client:
+    :path: Flutter/ephemeral/.symlinks/plugins/livekit_client/macos
+  path_provider_foundation:
+    :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
   record_macos:
   record_macos:
     :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos
     :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos
   shared_preferences_foundation:
   shared_preferences_foundation:
     :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
     :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
 
 
 SPEC CHECKSUMS:
 SPEC CHECKSUMS:
+  connectivity_plus: 0a976dfd033b59192912fa3c6c7b54aab5093802
+  device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
+  flutter_webrtc: dbacf72a9bd951ccfa5997198f0a6214bb848ede
   FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
   FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
+  livekit_client: 68cd113724768deae3063443af1ea47d5ba2f16f
+  path_provider_foundation: 0b743cbb62d8e47eab856f09262bb8c1ddcfe6ba
   record_macos: ccd7864c9922136e50c2937b21384f0b175922f2
   record_macos: ccd7864c9922136e50c2937b21384f0b175922f2
   shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
   shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
+  WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d
 
 
 PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
 PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
 
 

+ 264 - 0
flutter_asr_client/pubspec.lock

@@ -1,6 +1,14 @@
 # Generated by pub
 # Generated by pub
 # See https://dart.dev/tools/pub/glossary#lockfile
 # See https://dart.dev/tools/pub/glossary#lockfile
 packages:
 packages:
+  args:
+    dependency: transitive
+    description:
+      name: args
+      sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.7.0"
   async:
   async:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -41,6 +49,30 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "1.19.1"
     version: "1.19.1"
+  connectivity_plus:
+    dependency: transitive
+    description:
+      name: connectivity_plus
+      sha256: cad0e811a289ea2a941119dc483c204ec1684cbb9a8fc7351fe4a230b8313160
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "7.2.0"
+  connectivity_plus_platform_interface:
+    dependency: transitive
+    description:
+      name: connectivity_plus_platform_interface
+      sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.0"
+  convert:
+    dependency: transitive
+    description:
+      name: convert
+      sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.1.2"
   crypto:
   crypto:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -57,6 +89,46 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "1.0.9"
     version: "1.0.9"
+  dart_jsonwebtoken:
+    dependency: "direct main"
+    description:
+      name: dart_jsonwebtoken
+      sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.4.1"
+  dart_webrtc:
+    dependency: transitive
+    description:
+      name: dart_webrtc
+      sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.8.1"
+  dbus:
+    dependency: transitive
+    description:
+      name: dbus
+      sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.7.14"
+  device_info_plus:
+    dependency: transitive
+    description:
+      name: device_info_plus
+      sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "12.4.0"
+  device_info_plus_platform_interface:
+    dependency: transitive
+    description:
+      name: device_info_plus_platform_interface
+      sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "7.0.3"
   fake_async:
   fake_async:
     dependency: "direct dev"
     dependency: "direct dev"
     description:
     description:
@@ -125,6 +197,14 @@ packages:
     description: flutter
     description: flutter
     source: sdk
     source: sdk
     version: "0.0.0"
     version: "0.0.0"
+  flutter_webrtc:
+    dependency: transitive
+    description:
+      name: flutter_webrtc
+      sha256: "8b220dc006c4891266735e516f7679bd08b7caaf7c36b1a93fb9357cec555f92"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.4.0"
   fuchsia_remote_debug_protocol:
   fuchsia_remote_debug_protocol:
     dependency: transitive
     dependency: transitive
     description: flutter
     description: flutter
@@ -138,6 +218,22 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "14.8.1"
     version: "14.8.1"
+  http:
+    dependency: transitive
+    description:
+      name: http
+      sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.6.0"
+  http_parser:
+    dependency: transitive
+    description:
+      name: http_parser
+      sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.1.2"
   integration_test:
   integration_test:
     dependency: "direct dev"
     dependency: "direct dev"
     description: flutter
     description: flutter
@@ -151,6 +247,38 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "0.20.3"
     version: "0.20.3"
+  jni:
+    dependency: transitive
+    description:
+      name: jni
+      sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.0"
+  jni_flutter:
+    dependency: transitive
+    description:
+      name: jni_flutter
+      sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.1"
+  js:
+    dependency: transitive
+    description:
+      name: js
+      sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.7.2"
+  json_annotation:
+    dependency: transitive
+    description:
+      name: json_annotation
+      sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.12.0"
   leak_tracker:
   leak_tracker:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -183,6 +311,22 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "6.1.0"
     version: "6.1.0"
+  livekit_client:
+    dependency: "direct main"
+    description:
+      name: livekit_client
+      sha256: bd68a1e273b286f03be14636c12fbbb5cc0fc6dee64ec6d4756c55a4f408fc23
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.8.1"
+  logger:
+    dependency: transitive
+    description:
+      name: logger
+      sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.7.0"
   logging:
   logging:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -215,6 +359,14 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "1.17.0"
     version: "1.17.0"
+  mime_type:
+    dependency: transitive
+    description:
+      name: mime_type
+      sha256: d652b613e84dac1af28030a9fba82c0999be05b98163f9e18a0849c6e63838bb
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.0.1"
   mocktail:
   mocktail:
     dependency: "direct dev"
     dependency: "direct dev"
     description:
     description:
@@ -223,6 +375,22 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "1.0.5"
     version: "1.0.5"
+  nm:
+    dependency: transitive
+    description:
+      name: nm
+      sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.5.0"
+  package_config:
+    dependency: transitive
+    description:
+      name: package_config
+      sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.2.0"
   path:
   path:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -231,6 +399,30 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "1.9.1"
     version: "1.9.1"
+  path_provider:
+    dependency: transitive
+    description:
+      name: path_provider
+      sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.6"
+  path_provider_android:
+    dependency: transitive
+    description:
+      name: path_provider_android
+      sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.3.1"
+  path_provider_foundation:
+    dependency: transitive
+    description:
+      name: path_provider_foundation
+      sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.5.1"
   path_provider_linux:
   path_provider_linux:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -303,6 +495,14 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "0.2.1"
     version: "0.2.1"
+  petitparser:
+    dependency: transitive
+    description:
+      name: petitparser
+      sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "7.0.2"
   platform:
   platform:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -319,6 +519,14 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "2.1.8"
     version: "2.1.8"
+  pointycastle:
+    dependency: transitive
+    description:
+      name: pointycastle
+      sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "4.0.0"
   process:
   process:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -327,6 +535,14 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "5.0.5"
     version: "5.0.5"
+  protobuf:
+    dependency: transitive
+    description:
+      name: protobuf
+      sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "6.0.0"
   record:
   record:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:
@@ -399,6 +615,14 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "2.6.1"
     version: "2.6.1"
+  sdp_transform:
+    dependency: transitive
+    description:
+      name: sdp_transform
+      sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "0.3.2"
   shared_preferences:
   shared_preferences:
     dependency: "direct main"
     dependency: "direct main"
     description:
     description:
@@ -508,6 +732,14 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "0.3.1"
     version: "0.3.1"
+  synchronized:
+    dependency: transitive
+    description:
+      name: synchronized
+      sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "3.4.0"
   term_glyph:
   term_glyph:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -588,6 +820,30 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "3.1.0"
     version: "3.1.0"
+  webrtc_interface:
+    dependency: transitive
+    description:
+      name: webrtc_interface
+      sha256: c6f100eac5057d9a817a60473126f9828c796d42884d498af4f339c97b21014f
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "1.5.1"
+  win32:
+    dependency: transitive
+    description:
+      name: win32
+      sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "5.15.0"
+  win32_registry:
+    dependency: transitive
+    description:
+      name: win32_registry
+      sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "2.1.0"
   xdg_directories:
   xdg_directories:
     dependency: transitive
     dependency: transitive
     description:
     description:
@@ -596,6 +852,14 @@ packages:
       url: "https://pub.flutter-io.cn"
       url: "https://pub.flutter-io.cn"
     source: hosted
     source: hosted
     version: "1.1.0"
     version: "1.1.0"
+  xml:
+    dependency: transitive
+    description:
+      name: xml
+      sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
+      url: "https://pub.flutter-io.cn"
+    source: hosted
+    version: "6.6.1"
 sdks:
 sdks:
   dart: ">=3.10.0 <4.0.0"
   dart: ">=3.10.0 <4.0.0"
   flutter: ">=3.38.0"
   flutter: ">=3.38.0"

+ 2 - 0
flutter_asr_client/pubspec.yaml

@@ -18,6 +18,8 @@ dependencies:
   intl: ^0.20.2
   intl: ^0.20.2
   permission_handler: ^11.3.1
   permission_handler: ^11.3.1
   uuid: ^4.5.3
   uuid: ^4.5.3
+  livekit_client: ^2.8.1
+  dart_jsonwebtoken: ^3.4.1
 
 
 dev_dependencies:
 dev_dependencies:
   flutter_test:
   flutter_test:

+ 9 - 0
flutter_asr_client/windows/flutter/generated_plugin_registrant.cc

@@ -6,10 +6,19 @@
 
 
 #include "generated_plugin_registrant.h"
 #include "generated_plugin_registrant.h"
 
 
+#include <connectivity_plus/connectivity_plus_windows_plugin.h>
+#include <flutter_webrtc/flutter_web_r_t_c_plugin.h>
+#include <livekit_client/live_kit_plugin.h>
 #include <permission_handler_windows/permission_handler_windows_plugin.h>
 #include <permission_handler_windows/permission_handler_windows_plugin.h>
 #include <record_windows/record_windows_plugin_c_api.h>
 #include <record_windows/record_windows_plugin_c_api.h>
 
 
 void RegisterPlugins(flutter::PluginRegistry* registry) {
 void RegisterPlugins(flutter::PluginRegistry* registry) {
+  ConnectivityPlusWindowsPluginRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
+  FlutterWebRTCPluginRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("FlutterWebRTCPlugin"));
+  LiveKitPluginRegisterWithRegistrar(
+      registry->GetRegistrarForPlugin("LiveKitPlugin"));
   PermissionHandlerWindowsPluginRegisterWithRegistrar(
   PermissionHandlerWindowsPluginRegisterWithRegistrar(
       registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
       registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
   RecordWindowsPluginCApiRegisterWithRegistrar(
   RecordWindowsPluginCApiRegisterWithRegistrar(

+ 4 - 0
flutter_asr_client/windows/flutter/generated_plugins.cmake

@@ -3,11 +3,15 @@
 #
 #
 
 
 list(APPEND FLUTTER_PLUGIN_LIST
 list(APPEND FLUTTER_PLUGIN_LIST
+  connectivity_plus
+  flutter_webrtc
+  livekit_client
   permission_handler_windows
   permission_handler_windows
   record_windows
   record_windows
 )
 )
 
 
 list(APPEND FLUTTER_FFI_PLUGIN_LIST
 list(APPEND FLUTTER_FFI_PLUGIN_LIST
+  jni
 )
 )
 
 
 set(PLUGIN_BUNDLED_LIBRARIES)
 set(PLUGIN_BUNDLED_LIBRARIES)

+ 12 - 0
livekit-server/docker-compose.yml

@@ -0,0 +1,12 @@
+services:
+  livekit:
+    image: docker.1ms.run/livekit/livekit-server:latest
+    ports:
+      - "7880:7880"
+      - "7881:7881"
+      - "50000-50100:50000-50100/udp"
+    volumes:
+      - ./livekit.yaml:/etc/livekit.yaml
+    command: --config /etc/livekit.yaml --dev
+    extra_hosts:
+      - "host.docker.internal:host-gateway"

+ 9 - 0
livekit-server/livekit.yaml

@@ -0,0 +1,9 @@
+port: 7888
+rtc:
+  port_range_start: 50000
+  port_range_end: 50100
+  use_external_ip: false
+keys:
+  devkey: secretsecretsecretsecretsecret12
+logging:
+  level: info

binární
whisper-qt-client/whisper_asr/__pycache__/audio_processor.cpython-310.pyc


binární
whisper-qt-client/whisper_asr/__pycache__/qwen_engine.cpython-310.pyc


binární
whisper-qt-client/whisper_asr/__pycache__/transcript_processor.cpython-310.pyc


+ 12 - 43
whisper-qt-client/whisper_asr/qwen_engine.py

@@ -5,63 +5,36 @@ from typing import Optional
 class QwenASREngine:
 class QwenASREngine:
     def __init__(
     def __init__(
         self,
         self,
-        # model_id: str = "Qwen/Qwen3-ASR-1.7B",
-        model_id: str="Qwen/Qwen3-ASR-0.6B",
+        model_id: str = "Qwen/Qwen3-ASR-0.6B",
         device: str = "auto",
         device: str = "auto",
-        language: Optional[str] = None
+        language: Optional[str] = None,
     ):
     ):
         import torch
         import torch
         from qwen_asr import Qwen3ASRModel
         from qwen_asr import Qwen3ASRModel
-        
+
         self.model_id = model_id
         self.model_id = model_id
         self.language = language
         self.language = language
         self.sample_rate = 16000
         self.sample_rate = 16000
-        
+
         if device == "auto":
         if device == "auto":
             device = "cuda" if torch.cuda.is_available() else "cpu"
             device = "cuda" if torch.cuda.is_available() else "cpu"
-        
         self.device = device
         self.device = device
         dtype = torch.float16 if device == "cuda" else torch.float32
         dtype = torch.float16 if device == "cuda" else torch.float32
-        
-        print(f"Loading Qwen3-ASR model: {model_id} on {device}")
-        
+
+        print(f"Loading Qwen3-ASR: {model_id} on {device}")
         self.model = Qwen3ASRModel.from_pretrained(
         self.model = Qwen3ASRModel.from_pretrained(
-            model_id,
-            dtype=dtype,
-            device_map=device
+            model_id, dtype=dtype, device_map=device,
         )
         )
-        print("Model loaded successfully")
+        print("ASR model ready")
 
 
-    def transcribe_audio(
-        self,
-        audio_data: bytes,
-        sample_rate: int = 16000
-    ) -> dict:
-        audio_array = self._bytes_to_array(audio_data)
-        return self.transcribe_array(audio_array, sample_rate)
-
-    def transcribe_array(
-        self,
-        audio_array: np.ndarray,
-        sample_rate: int = 16000
-    ) -> dict:
+    def transcribe_array(self, audio_array: np.ndarray, sample_rate: int = 16000) -> dict:
         if sample_rate != self.sample_rate:
         if sample_rate != self.sample_rate:
             audio_array = self._resample(audio_array, sample_rate, self.sample_rate)
             audio_array = self._resample(audio_array, sample_rate, self.sample_rate)
-        
         result = self.model.transcribe(
         result = self.model.transcribe(
             audio=(audio_array, self.sample_rate),
             audio=(audio_array, self.sample_rate),
-            language=self.language
+            language=self.language,
         )
         )
-        
-        return {
-            'text': result[0].text,
-            'segments': [{'text': result[0].text}],
-            'language': result[0].language
-        }
-
-    def _bytes_to_array(self, audio_bytes: bytes) -> np.ndarray:
-        int16_array = np.frombuffer(audio_bytes, dtype=np.int16)
-        return int16_array.astype(np.float32) / 32768.0
+        return {"text": result[0].text, "language": result[0].language}
 
 
     def _resample(self, audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
     def _resample(self, audio: np.ndarray, orig_sr: int, target_sr: int) -> np.ndarray:
         if orig_sr == target_sr:
         if orig_sr == target_sr:
@@ -72,8 +45,4 @@ class QwenASREngine:
         return np.interp(indices, np.arange(len(audio)), audio)
         return np.interp(indices, np.arange(len(audio)), audio)
 
 
     def get_model_info(self) -> dict:
     def get_model_info(self) -> dict:
-        return {
-            'model_id': self.model_id,
-            'device': self.device,
-            'language': self.language
-        }
+        return {"model_id": self.model_id, "device": self.device}