feat: add TTS voice and style settings to Flutter app

Add user-configurable TTS voice and tone style settings that flow from
the Flutter app through the backend to the voice-agent at call time.

## Flutter App (it0_app)

### Domain Layer
- app_settings.dart: Add `ttsVoice` (default: 'coral') and `ttsStyle`
  (default: '') fields to AppSettings entity with copyWith support

### Data Layer
- settings_datasource.dart: Add SharedPreferences keys
  `settings_tts_voice` and `settings_tts_style` for local persistence
  in loadSettings(), saveSettings(), and clearSettings()

### Presentation Layer
- settings_providers.dart: Add `setTtsVoice()` and `setTtsStyle()`
  methods to SettingsNotifier for Riverpod state management
- settings_page.dart: Add "语音" settings group between Notifications
  and Security groups with:
  - Voice picker: 13 OpenAI voices with gender/style labels
    (e.g. "女 · 温暖", "男 · 沉稳", "中性") in a BottomSheet
  - Style picker: 5 presets (专业干练/温柔耐心/轻松活泼/严肃正式/科幻AI)
    as ChoiceChips + custom text input field + reset button

### Call Flow
- agent_call_page.dart: Send `tts_voice` and `tts_style` in the POST
  body when requesting a LiveKit token at call initiation

## Backend

### voice-service (Python/FastAPI)
- livekit_token.py: Accept optional `tts_voice` and `tts_style` via
  Pydantic TokenRequest body model; embed them in RoomAgentDispatch
  metadata JSON alongside auth_header (backward compatible)

### voice-agent (Python/LiveKit Agents)
- agent.py: Extract `tts_voice` and `tts_style` from ctx.job.metadata;
  use them when creating openai_plugin.TTS() — user-selected voice
  overrides config default, user-selected style overrides default
  instructions. Falls back to config defaults when not provided.

## Data Flow
Flutter Settings → SharedPreferences → POST /livekit/token body →
voice-service embeds in RoomAgentDispatch metadata →
voice-agent reads from ctx.job.metadata → TTS creation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-01 09:38:15 -08:00
parent 2dc361f7a0
commit 5460be8c04
9 changed files with 473 additions and 9 deletions

View File

@ -20,6 +20,21 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin audio_session, com.ryanheise.audio_session.AudioSessionPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.connectivity.ConnectivityPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin connectivity_plus, dev.fluttercommunity.plus.connectivity.ConnectivityPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin device_info_plus, dev.fluttercommunity.plus.device_info.DeviceInfoPlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin());
} catch (Exception e) {
@ -45,11 +60,21 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_tts, com.eyedeadevelopment.fluttertts.FlutterTtsPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.cloudwebrtc.webrtc.FlutterWebRTCPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_webrtc, com.cloudwebrtc.webrtc.FlutterWebRTCPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.livekit.plugin.LiveKitPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin livekit_client, io.livekit.plugin.LiveKitPlugin", e);
}
try {
flutterEngine.getPlugins().add(new dev.fluttercommunity.plus.packageinfo.PackageInfoPlugin());
} catch (Exception e) {

View File

@ -7,6 +7,7 @@ import '../../../../core/config/api_endpoints.dart';
import '../../../../core/config/app_config.dart';
import '../../../../core/network/dio_client.dart';
import '../../../../core/theme/app_colors.dart';
import '../../../settings/presentation/providers/settings_providers.dart';
/// Tracks the current state of the voice call.
enum _CallPhase { ringing, connecting, active, ended }
@ -68,8 +69,15 @@ class _AgentCallPageState extends ConsumerState<AgentCallPage>
final dio = ref.read(dioClientProvider);
final config = ref.read(appConfigProvider);
// 1. Get LiveKit token from backend
final response = await dio.post(ApiEndpoints.livekitToken);
// 1. Get LiveKit token from backend (with voice preferences)
final voiceSettings = ref.read(settingsProvider);
final response = await dio.post(
ApiEndpoints.livekitToken,
data: {
if (voiceSettings.ttsVoice.isNotEmpty) 'tts_voice': voiceSettings.ttsVoice,
if (voiceSettings.ttsStyle.isNotEmpty) 'tts_style': voiceSettings.ttsStyle,
},
);
final data = response.data as Map<String, dynamic>;
final token = data['token'] as String;
final livekitUrl = data['livekit_url'] as String? ?? config.livekitUrl;

View File

@ -14,6 +14,8 @@ class SettingsDatasource {
static const String _keyTenantName = 'settings_tenant_name';
static const String _keyLanguage = 'settings_language';
static const String _keyBiometric = 'settings_biometric';
static const String _keyTtsVoice = 'settings_tts_voice';
static const String _keyTtsStyle = 'settings_tts_style';
SettingsDatasource(this._prefs);
@ -31,6 +33,8 @@ class SettingsDatasource {
selectedTenantName: _prefs.getString(_keyTenantName),
language: _prefs.getString(_keyLanguage) ?? 'en',
biometricEnabled: _prefs.getBool(_keyBiometric) ?? false,
ttsVoice: _prefs.getString(_keyTtsVoice) ?? 'coral',
ttsStyle: _prefs.getString(_keyTtsStyle) ?? '',
);
}
@ -55,6 +59,8 @@ class SettingsDatasource {
await _prefs.setString(_keyLanguage, settings.language);
await _prefs.setBool(_keyBiometric, settings.biometricEnabled);
await _prefs.setString(_keyTtsVoice, settings.ttsVoice);
await _prefs.setString(_keyTtsStyle, settings.ttsStyle);
}
/// Removes all settings keys from SharedPreferences.
@ -67,5 +73,7 @@ class SettingsDatasource {
await _prefs.remove(_keyTenantName);
await _prefs.remove(_keyLanguage);
await _prefs.remove(_keyBiometric);
await _prefs.remove(_keyTtsVoice);
await _prefs.remove(_keyTtsStyle);
}
}

View File

@ -10,6 +10,8 @@ class AppSettings {
final String? selectedTenantName;
final String language;
final bool biometricEnabled;
final String ttsVoice;
final String ttsStyle;
const AppSettings({
this.themeMode = ThemeMode.dark,
@ -20,6 +22,8 @@ class AppSettings {
this.selectedTenantName,
this.language = 'en',
this.biometricEnabled = false,
this.ttsVoice = 'coral',
this.ttsStyle = '',
});
AppSettings copyWith({
@ -31,6 +35,8 @@ class AppSettings {
String? selectedTenantName,
String? language,
bool? biometricEnabled,
String? ttsVoice,
String? ttsStyle,
}) {
return AppSettings(
themeMode: themeMode ?? this.themeMode,
@ -41,6 +47,8 @@ class AppSettings {
selectedTenantName: selectedTenantName ?? this.selectedTenantName,
language: language ?? this.language,
biometricEnabled: biometricEnabled ?? this.biometricEnabled,
ttsVoice: ttsVoice ?? this.ttsVoice,
ttsStyle: ttsStyle ?? this.ttsStyle,
);
}
}

View File

@ -110,6 +110,34 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
),
const SizedBox(height: 24),
// ===== Voice Group =====
_SettingsGroup(
cardColor: cardColor,
children: [
_SettingsRow(
icon: Icons.record_voice_over,
iconBg: const Color(0xFF0EA5E9),
title: '语音音色',
trailing: Text(
_voiceDisplayLabel(settings.ttsVoice),
style: TextStyle(color: subtitleColor, fontSize: 14),
),
onTap: () => _showVoicePicker(settings.ttsVoice),
),
_SettingsRow(
icon: Icons.tune,
iconBg: const Color(0xFFF97316),
title: '语音风格',
trailing: Text(
_styleDisplayName(settings.ttsStyle),
style: TextStyle(color: subtitleColor, fontSize: 14),
),
onTap: () => _showStylePicker(settings.ttsStyle),
),
],
),
const SizedBox(height: 24),
// ===== Security Group =====
_SettingsGroup(
cardColor: cardColor,
@ -330,6 +358,219 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
);
}
// ---- Voice Picker ----------------------------------------------------------
static const _voices = [
('coral', 'Coral', '女 · 温暖'),
('nova', 'Nova', '女 · 活泼'),
('sage', 'Sage', '女 · 知性'),
('shimmer', 'Shimmer', '女 · 柔和'),
('marin', 'Marin', '女 · 清澈'),
('ash', 'Ash', '男 · 沉稳'),
('echo', 'Echo', '男 · 清朗'),
('onyx', 'Onyx', '男 · 低沉'),
('verse', 'Verse', '男 · 磁性'),
('ballad', 'Ballad', '男 · 浑厚'),
('cedar', 'Cedar', '男 · 自然'),
('alloy', 'Alloy', '中性'),
('fable', 'Fable', '中性 · 叙事'),
];
void _showVoicePicker(String current) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 12),
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Text('选择语音音色',
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: 8),
Flexible(
child: ListView(
shrinkWrap: true,
children: _voices
.map((v) => ListTile(
leading: Icon(
Icons.record_voice_over,
color: current == v.$1
? Theme.of(ctx).colorScheme.primary
: null,
),
title: Text(
v.$2,
style: TextStyle(
fontWeight: current == v.$1
? FontWeight.w600
: FontWeight.normal,
color: current == v.$1
? Theme.of(ctx).colorScheme.primary
: null,
),
),
subtitle: Text(v.$3,
style: TextStyle(
fontSize: 12,
color: Theme.of(ctx).hintColor)),
trailing: current == v.$1
? Icon(Icons.check_circle,
color:
Theme.of(ctx).colorScheme.primary)
: null,
onTap: () {
ref
.read(settingsProvider.notifier)
.setTtsVoice(v.$1);
Navigator.pop(ctx);
},
))
.toList(),
),
),
const SizedBox(height: 16),
],
),
);
},
);
}
// ---- Style Picker ---------------------------------------------------------
String _voiceDisplayLabel(String voice) {
for (final v in _voices) {
if (v.$1 == voice) return '${v.$2} · ${v.$3}';
}
return voice[0].toUpperCase() + voice.substring(1);
}
static const _stylePresets = [
('专业干练', '用专业、简洁、干练的语气说话,不拖泥带水。'),
('温柔耐心', '用温柔、耐心的语气说话,像一个贴心的朋友。'),
('轻松活泼', '用轻松、活泼的语气说话,带一点幽默感。'),
('严肃正式', '用严肃、正式的语气说话,像在正式会议中发言。'),
('科幻AI', '用科幻电影中AI的语气说话冷静、理性、略带未来感。'),
];
String _styleDisplayName(String style) {
if (style.isEmpty) return '默认';
for (final p in _stylePresets) {
if (p.$2 == style) return p.$1;
}
return style.length > 6 ? '${style.substring(0, 6)}...' : style;
}
void _showStylePicker(String current) {
final controller = TextEditingController(
text: _stylePresets.any((p) => p.$2 == current) ? '' : current,
);
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return Padding(
padding: EdgeInsets.fromLTRB(
24, 24, 24, MediaQuery.of(ctx).viewInsets.bottom + 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey[400],
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: 16),
Text('选择语音风格',
style: Theme.of(ctx).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
)),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: _stylePresets
.map((p) => ChoiceChip(
label: Text(p.$1),
selected: current == p.$2,
onSelected: (_) {
ref
.read(settingsProvider.notifier)
.setTtsStyle(p.$2);
Navigator.pop(ctx);
},
))
.toList(),
),
const SizedBox(height: 16),
TextField(
controller: controller,
decoration: InputDecoration(
labelText: '自定义风格',
hintText: '例如:用东北话说话,幽默风趣',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
),
maxLines: 2,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () {
ref.read(settingsProvider.notifier).setTtsStyle('');
Navigator.pop(ctx);
},
child: const Text('恢复默认'),
),
),
const SizedBox(width: 12),
Expanded(
child: FilledButton(
onPressed: () {
final text = controller.text.trim();
if (text.isNotEmpty) {
ref
.read(settingsProvider.notifier)
.setTtsStyle(text);
}
Navigator.pop(ctx);
},
child: const Text('确认'),
),
),
],
),
],
),
);
},
);
}
// ---- Edit Name Dialog -----------------------------------------------------
void _showEditNameDialog(String currentName) {

View File

@ -124,6 +124,16 @@ class SettingsNotifier extends StateNotifier<AppSettings> {
await _repository?.saveSettings(state);
}
Future<void> setTtsVoice(String voice) async {
state = state.copyWith(ttsVoice: voice);
await _repository?.saveSettings(state);
}
Future<void> setTtsStyle(String style) async {
state = state.copyWith(ttsStyle: style);
await _repository?.saveSettings(state);
}
Future<void> resetToDefaults() async {
await _repository?.resetSettings();
state = const AppSettings();

View File

@ -9,6 +9,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "85.0.0"
adaptive_number:
dependency: transitive
description:
name: adaptive_number
sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
analyzer:
dependency: transitive
description:
@ -177,6 +185,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
connectivity_plus:
dependency: transitive
description:
name: connectivity_plus
sha256: "33bae12a398f841c6cda09d1064212957265869104c478e5ad51e2fb26c3973c"
url: "https://pub.dev"
source: hosted
version: "7.0.0"
connectivity_plus_platform_interface:
dependency: transitive
description:
name: connectivity_plus_platform_interface
sha256: "42657c1715d48b167930d5f34d00222ac100475f73d10162ddf43e714932f204"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
convert:
dependency: transitive
description:
@ -225,6 +249,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0+7.7.0"
dart_jsonwebtoken:
dependency: transitive
description:
name: dart_jsonwebtoken
sha256: c6ecb3bb991c459b91c5adf9e871113dcb32bbe8fe7ca2c92723f88ffc1e0b7a
url: "https://pub.dev"
source: hosted
version: "3.3.2"
dart_style:
dependency: transitive
description:
@ -233,6 +265,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.1"
dart_webrtc:
dependency: transitive
description:
name: dart_webrtc
sha256: "4ed7b9fa9924e5a81eb39271e2c2356739dd1039d60a13b86ba6c5f448625086"
url: "https://pub.dev"
source: hosted
version: "1.7.0"
dbus:
dependency: transitive
description:
@ -241,6 +281,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.12"
device_info_plus:
dependency: transitive
description:
name: device_info_plus
sha256: "4df8babf73058181227e18b08e6ea3520cf5fc5d796888d33b7cb0f33f984b7c"
url: "https://pub.dev"
source: hosted
version: "12.3.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
url: "https://pub.dev"
source: hosted
version: "7.0.3"
dio:
dependency: "direct main"
description:
@ -257,6 +313,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.1"
ed25519_edwards:
dependency: transitive
description:
name: ed25519_edwards
sha256: "6ce0112d131327ec6d42beede1e5dfd526069b18ad45dcf654f15074ad9276cd"
url: "https://pub.dev"
source: hosted
version: "0.3.1"
equatable:
dependency: transitive
description:
@ -289,6 +353,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
name: file_picker
sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810
url: "https://pub.dev"
source: hosted
version: "8.3.7"
file_selector_linux:
dependency: transitive
description:
@ -504,6 +576,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_webrtc:
dependency: transitive
description:
name: flutter_webrtc
sha256: "0f86b518e9349e71a136a96e0ea11294cad8a8531b2bc9ae99e69df332ac898a"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
freezed:
dependency: "direct dev"
description:
@ -744,6 +824,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
livekit_client:
dependency: "direct main"
description:
name: livekit_client
sha256: "51d97a4501e385e53c140b8367a52316af5b466e71e6d800c8826065b6061521"
url: "https://pub.dev"
source: hosted
version: "2.6.4"
logger:
dependency: "direct main"
description:
@ -792,6 +880,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mime_type:
dependency: transitive
description:
name: mime_type
sha256: d652b613e84dac1af28030a9fba82c0999be05b98163f9e18a0849c6e63838bb
url: "https://pub.dev"
source: hosted
version: "1.0.1"
mockito:
dependency: "direct dev"
description:
@ -808,6 +904,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.0"
nm:
dependency: transitive
description:
name: nm
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
url: "https://pub.dev"
source: hosted
version: "0.5.0"
package_config:
dependency: transitive
description:
@ -968,6 +1072,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pointycastle:
dependency: transitive
description:
name: pointycastle
sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
pool:
dependency: transitive
description:
@ -984,6 +1096,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.5.0"
protobuf:
dependency: transitive
description:
name: protobuf
sha256: "75ec242d22e950bdcc79ee38dd520ce4ee0bc491d7fadc4ea47694604d22bf06"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
provider:
dependency: transitive
description:
@ -1120,6 +1240,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
sdp_transform:
dependency: transitive
description:
name: sdp_transform
sha256: "73e412a5279a5c2de74001535208e20fff88f225c9a4571af0f7146202755e45"
url: "https://pub.dev"
source: hosted
version: "0.3.2"
shared_preferences:
dependency: "direct main"
description:
@ -1517,6 +1645,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
webrtc_interface:
dependency: transitive
description:
name: webrtc_interface
sha256: ad0e5786b2acd3be72a3219ef1dde9e1cac071cf4604c685f11b61d63cdd6eb3
url: "https://pub.dev"
source: hosted
version: "1.4.0"
win32:
dependency: transitive
description:
@ -1525,6 +1661,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.15.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
xdg_directories:
dependency: transitive
description:

View File

@ -125,14 +125,19 @@ async def entrypoint(ctx: JobContext) -> None:
# The token endpoint embeds {"auth_header": "Bearer ..."} via RoomAgentDispatch metadata,
# which LiveKit passes through as job.metadata to the agent worker.
auth_header = ""
tts_voice = settings.openai_tts_voice
tts_style = ""
try:
meta_str = ctx.job.metadata or "{}"
meta = json.loads(meta_str)
auth_header = meta.get("auth_header", "")
tts_voice = meta.get("tts_voice", settings.openai_tts_voice)
tts_style = meta.get("tts_style", "")
except Exception as e:
logger.warning("Failed to parse job metadata: %s", e)
logger.info("Auth header present: %s", bool(auth_header))
logger.info("Auth header present: %s, TTS: voice=%s, style=%s",
bool(auth_header), tts_voice, tts_style[:50] if tts_style else "(default)")
# Build STT
if settings.stt_provider == "openai":
@ -165,10 +170,11 @@ async def entrypoint(ctx: JobContext) -> None:
_http_client_tts = _httpx.AsyncClient(verify=False)
_oai_client_tts = _openai.AsyncOpenAI(http_client=_http_client_tts)
default_instructions = "用自然、友好的中文语气说话,语速适中,像真人助手一样。"
tts = openai_plugin.TTS(
model=settings.openai_tts_model,
voice=settings.openai_tts_voice,
instructions="用自然、友好的中文语气说话,语速适中,像真人助手一样。",
voice=tts_voice,
instructions=tts_style if tts_style else default_instructions,
client=_oai_client_tts,
)
else:

View File

@ -9,8 +9,10 @@ Generates a LiveKit room JWT for voice calls. The token includes:
import json
import uuid
from typing import Optional
from fastapi import APIRouter, Request
from pydantic import BaseModel
from livekit import api as livekit_api
@ -19,18 +21,30 @@ from ..config.settings import settings
router = APIRouter()
class TokenRequest(BaseModel):
tts_voice: Optional[str] = None
tts_style: Optional[str] = None
@router.post("/livekit/token")
async def create_livekit_token(request: Request):
async def create_livekit_token(request: Request, body: TokenRequest = TokenRequest()):
"""Generate a LiveKit room token for a voice call session.
The caller's Authorization header is embedded in the agent dispatch
metadata so that the voice-agent can forward it to agent-service.
The caller's Authorization header and optional TTS preferences are
embedded in the agent dispatch metadata so the voice-agent can use them.
"""
auth_header = request.headers.get("authorization", "")
room_name = f"voice-{uuid.uuid4().hex[:12]}"
participant_identity = f"user-{uuid.uuid4().hex[:8]}"
# Build metadata with auth + optional voice preferences
metadata: dict = {"auth_header": auth_header}
if body.tts_voice:
metadata["tts_voice"] = body.tts_voice
if body.tts_style:
metadata["tts_style"] = body.tts_style
token = (
livekit_api.AccessToken(settings.livekit_api_key, settings.livekit_api_secret)
.with_identity(participant_identity)
@ -49,7 +63,7 @@ async def create_livekit_token(request: Request):
agents=[
livekit_api.RoomAgentDispatch(
agent_name="voice-agent",
metadata=json.dumps({"auth_header": auth_header}),
metadata=json.dumps(metadata),
)
],
)