Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e28c475
Enable SDK logging
hiroshihorie Aug 17, 2025
b7d9c9b
Bump macOS deployment target
hiroshihorie Aug 17, 2025
b841ce3
Pre-connect audio buffer
hiroshihorie Aug 17, 2025
05c4d4e
Merge branch 'main' into hiroshi/pre-connect-audio
hiroshihorie Aug 17, 2025
3b09957
Use local packages temporarily
hiroshihorie Sep 2, 2025
ba1d8e8
Merge branch 'main' into hiroshi/pre-connect-audio
hiroshihorie Sep 5, 2025
eeeb143
Logging
hiroshihorie Sep 7, 2025
182ccf8
Agent listening indicator
hiroshihorie Sep 8, 2025
187e3f2
Fix event
hiroshihorie Sep 8, 2025
a0c5ce7
Update android settings
hiroshihorie Sep 8, 2025
2f08d38
Merge branch 'main' into hiroshi/pre-connect-audio
hiroshihorie Sep 9, 2025
b8f367b
Update pubspec.yaml
hiroshihorie Sep 9, 2025
1d3de0d
Update pubspec.yaml
hiroshihorie Sep 9, 2025
5477483
Merge branch 'hiroshi/pre-connect-audio' of https://github.com/liveki…
hiroshihorie Sep 9, 2025
ad24c6a
Merge branch 'main' into hiroshi/pre-connect-audio
hiroshihorie Sep 9, 2025
377228c
Adjust progress indicator
hiroshihorie Sep 9, 2025
e6d8b9d
Update Podfile.lock
hiroshihorie Sep 9, 2025
b8f1554
Merge branch 'main' into hiroshi/pre-connect-audio
hiroshihorie Sep 9, 2025
6a6284d
Merge branch 'hiroshi/pre-connect-audio' of https://github.com/liveki…
hiroshihorie Sep 21, 2025
253a350
Merge branch 'main' into hiroshi/pre-connect-audio
hiroshihorie Oct 1, 2025
a17465e
swift format
hiroshihorie Nov 13, 2025
8fdeb86
Update GeneratedPluginRegistrant.swift
hiroshihorie Nov 13, 2025
b531d37
progress
hiroshihorie Nov 14, 2025
78af416
minor fix
hiroshihorie Nov 14, 2025
7767888
minor adjustments
hiroshihorie Nov 17, 2025
a28f8f6
minor adjustments
hiroshihorie Nov 17, 2025
dcac9d1
update lock
hiroshihorie Dec 2, 2025
abade0a
Use new session api
hiroshihorie Dec 2, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 34 additions & 22 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -1,28 +1,40 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# Copyright 2025 LiveKit
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
include: package:lints/recommended.yaml

analyzer:
errors:
constant_identifier_names: ignore
no_wildcard_variable_uses: ignore
use_super_parameters: ignore
avoid_print: ignore
deprecated_member_use_from_same_package: ignore

linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Preference of the SDK
prefer_single_quotes: true
prefer_final_locals: true
unnecessary_brace_in_string_interps: false
avoid_print: true

# Enforce this for correct async logic
unawaited_futures: true
discarded_futures: true

# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
formatter:
page_width: 120
trailing_commas: preserve
20 changes: 10 additions & 10 deletions ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,19 @@ PODS:
- device_info_plus (0.0.1):
- Flutter
- Flutter (1.0.0)
- flutter_webrtc (0.14.0):
- flutter_webrtc (1.2.0):
- Flutter
- WebRTC-SDK (= 125.6422.07)
- livekit_client (2.4.9):
- WebRTC-SDK (= 137.7151.04)
- livekit_client (2.5.4):
- Flutter
- flutter_webrtc
- WebRTC-SDK (= 125.6422.07)
- WebRTC-SDK (= 137.7151.04)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- url_launcher_ios (0.0.1):
- Flutter
- WebRTC-SDK (125.6422.07)
- WebRTC-SDK (137.7151.04)

DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
Expand Down Expand Up @@ -51,11 +51,11 @@ SPEC CHECKSUMS:
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_webrtc: fd0d3bdef8766a0736dbbe2e5b7e85f1f3c52117
livekit_client: 3f79d79233a5bd13d5b541732624ef959d7c538e
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
WebRTC-SDK: dff00a3892bc570b6014e046297782084071657e
flutter_webrtc: c3e21fc0dcd9d8eb246ae4d5256fcbeb2f5ecd22
livekit_client: 53ca658779b78710fb458cccee28b53a13356c15
path_provider_foundation: bb55f6dbba17d0dccd6737fe6f7f34fbd0376880
url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b
WebRTC-SDK: 40d4f5ba05cadff14e4db5614aec402a633f007e

PODFILE CHECKSUM: a57f30d18f102dd3ce366b1d62a55ecbef2158e5

Expand Down
14 changes: 7 additions & 7 deletions ios/Runner/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import UIKit

@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}
10 changes: 4 additions & 6 deletions ios/RunnerTests/RunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,8 @@ import UIKit
import XCTest

class RunnerTests: XCTestCase {

func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}

func testExample() {
// If you add code to the Runner application, consider adding tests here.
// See https://developer.apple.com/documentation/xctest for more information about using XCTest.
}
}
29 changes: 17 additions & 12 deletions lib/app.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:livekit_components/livekit_components.dart' as components;
import 'package:provider/provider.dart';

import 'controllers/app_ctrl.dart';
Expand Down Expand Up @@ -57,20 +58,24 @@ class VoiceAssistantApp extends StatelessWidget {
Widget build(BuildContext ctx) => MultiProvider(
providers: [
ChangeNotifierProvider.value(value: appCtrl),
ChangeNotifierProvider.value(value: appCtrl.session),
ChangeNotifierProvider.value(value: appCtrl.roomContext),
],
child: MaterialApp(
title: 'Voice Assistant',
theme: buildTheme(isLight: true),
darkTheme: buildTheme(isLight: false),
// themeMode: ThemeMode.dark,
home: Builder(
builder: (ctx) => Selector<AppCtrl, AppScreenState>(
selector: (ctx, appCtx) => appCtx.appScreenState,
builder: (ctx, screen, _) => AppLayoutSwitcher(
frontBuilder: (ctx) => const WelcomeScreen(),
backBuilder: (ctx) => const AgentScreen(),
isFront: screen == AppScreenState.welcome,
child: components.SessionScope(
session: appCtrl.session,
child: MaterialApp(
title: 'Voice Assistant',
theme: buildTheme(isLight: true),
darkTheme: buildTheme(isLight: false),
// themeMode: ThemeMode.dark,
home: Builder(
builder: (ctx) => Selector<AppCtrl, AppScreenState>(
selector: (ctx, appCtx) => appCtx.appScreenState,
builder: (ctx, screen, _) => AppLayoutSwitcher(
frontBuilder: (ctx) => const WelcomeScreen(),
backBuilder: (ctx) => const AgentScreen(),
isFront: screen == AppScreenState.welcome,
),
),
),
),
Expand Down
146 changes: 53 additions & 93 deletions lib/controllers/app_ctrl.dart
Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:livekit_client/livekit_client.dart' as sdk;
import 'package:livekit_components/livekit_components.dart' as components;
import 'package:logging/logging.dart';
import 'package:uuid/uuid.dart';

import '../exts.dart';
import '../services/token_service.dart';

enum AppScreenState { welcome, agent }

enum AgentScreenState { visualizer, transcription }

enum ConnectionState { disconnected, connecting, connected }

class AppCtrl extends ChangeNotifier {
static const uuid = Uuid();
static final _logger = Logger('AppCtrl');

// States
AppScreenState appScreenState = AppScreenState.welcome;
ConnectionState connectionState = ConnectionState.disconnected;
AgentScreenState agentScreenState = AgentScreenState.visualizer;

//Test
Expand All @@ -32,15 +26,16 @@ class AppCtrl extends ChangeNotifier {
final messageCtrl = TextEditingController();
final messageFocusNode = FocusNode();

final tokenService = TokenService();
late final sdk.Room room = sdk.Room(roomOptions: const sdk.RoomOptions(enableVisualizer: true));
late final roomContext = components.RoomContext(room: room);

final tokenService = TokenService();
late final sdk.Session session = sdk.Session.fromConfigurableTokenSource(
TokenServiceTokenSource(tokenService),
options: sdk.SessionOptions(room: room),
);

bool isSendButtonEnabled = false;

// Timer for checking agent connection
Timer? _agentConnectionTimer;
bool isSessionStarting = false;

AppCtrl() {
final format = DateFormat('HH:mm:ss');
Expand All @@ -57,12 +52,17 @@ class AppCtrl extends ChangeNotifier {
notifyListeners();
}
});

session.addListener(_handleSessionChange);
}

@override
void dispose() {
void dispose() async {
session.removeListener(_handleSessionChange);
await session.dispose();
roomContext.dispose();
messageCtrl.dispose();
_cancelAgentTimer();
messageFocusNode.dispose();
super.dispose();
}

Expand All @@ -73,15 +73,8 @@ class AppCtrl extends ChangeNotifier {
messageCtrl.clear();
notifyListeners();

final lp = room.localParticipant;
if (lp == null) return;

final nowUtc = DateTime.now().toUtc();
final segment = sdk.TranscriptionSegment(
id: uuid.v4(), text: text, firstReceivedTime: nowUtc, lastReceivedTime: nowUtc, isFinal: true, language: 'en');
roomContext.insertTranscription(components.TranscriptionForParticipant(segment, lp));

await lp.sendText(text, options: sdk.SendTextOptions(topic: 'lk.chat'));
if (text.isEmpty) return;
await session.sendText(text);
}

void toggleUserCamera(components.MediaDeviceContext? deviceCtx) {
Expand All @@ -102,90 +95,57 @@ class AppCtrl extends ChangeNotifier {
}

void connect() async {
_logger.info("Connect....");
connectionState = ConnectionState.connecting;
if (isSessionStarting) {
_logger.fine('Connection attempt ignored: session already starting.');
return;
}

_logger.info('Starting session connection…');
isSessionStarting = true;
notifyListeners();

try {
// Generate random room and participant names
// In a real app, you'd likely use meaningful names
final roomName = 'room-${(1000 + DateTime.now().millisecondsSinceEpoch % 9000)}';
final participantName = 'user-${(1000 + DateTime.now().millisecondsSinceEpoch % 9000)}';

// Get connection details from token service
final connectionDetails = await tokenService.fetchConnectionDetails(
roomName: roomName,
participantName: participantName,
);

_logger.info("Fetched Connection Details: $connectionDetails, connecting to room...");

await room.connect(
connectionDetails.serverUrl,
connectionDetails.participantToken,
);

_logger.info("Connected to room");

await room.localParticipant?.setMicrophoneEnabled(true);

_logger.info("Microphone enabled");

connectionState = ConnectionState.connected;
appScreenState = AppScreenState.agent;

// Start the 20-second timer to check for AGENT participant
_startAgentConnectionTimer();

notifyListeners();
} catch (error) {
_logger.severe('Connection error: $error');

connectionState = ConnectionState.disconnected;
await session.start();
if (session.connectionState == sdk.ConnectionState.connected) {
appScreenState = AppScreenState.agent;
notifyListeners();
}
} catch (error, stackTrace) {
_logger.severe('Connection error: $error', error, stackTrace);
appScreenState = AppScreenState.welcome;
notifyListeners();
} finally {
if (isSessionStarting) {
isSessionStarting = false;
notifyListeners();
}
}
}

void disconnect() {
room.disconnect();
_cancelAgentTimer();

// Update states
connectionState = ConnectionState.disconnected;
void disconnect() async {
await session.end();
session.restoreMessageHistory(const []);
appScreenState = AppScreenState.welcome;
agentScreenState = AgentScreenState.visualizer;

notifyListeners();
}

// Start a 20-second timer to check for agent connection
void _startAgentConnectionTimer() {
_cancelAgentTimer(); // Cancel any existing timer
_logger.info("Starting 20-second timer to check for AGENT participant...");

_agentConnectionTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
// Check if there's an agent participant
final hasAgent = room.remoteParticipants.values.any((participant) => participant.isAgent);

if (hasAgent) {
_logger.info("AGENT participant found, cancelling timer");
_cancelAgentTimer();
return;
}

// If 10 seconds have elapsed and no agent found, disconnect
if (timer.tick >= 20) {
_logger.warning("No AGENT participant found after 20 seconds, disconnecting...");
_cancelAgentTimer();
disconnect();
}
});
}
void _handleSessionChange() {
final sdk.ConnectionState state = session.connectionState;
AppScreenState? nextScreen;
switch (state) {
case sdk.ConnectionState.connected:
case sdk.ConnectionState.reconnecting:
nextScreen = AppScreenState.agent;
case sdk.ConnectionState.disconnected:
nextScreen = AppScreenState.welcome;
case sdk.ConnectionState.connecting:
nextScreen = null;
}

// Cancel the agent connection timer
void _cancelAgentTimer() {
_agentConnectionTimer?.cancel();
_agentConnectionTimer = null;
if (nextScreen != null && nextScreen != appScreenState) {
appScreenState = nextScreen;
notifyListeners();
}
}
}
Loading
Loading