You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
885 lines
28 KiB
885 lines
28 KiB
import 'dart:async';
|
|
|
|
import 'package:flutter/services.dart';
|
|
|
|
// device_plugin_interface 适配器(让 device_manager 以厂商无关方式驱动 jieli)。
|
|
export 'src/jieli_device_plugin.dart';
|
|
export 'src/jieli_device_session.dart';
|
|
|
|
const _methodChannel = MethodChannel('device_jieli/method');
|
|
const _eventChannel = EventChannel('device_jieli/event');
|
|
|
|
class JieliDevice {
|
|
JieliDevice({
|
|
required this.name,
|
|
required this.address,
|
|
this.edrAddr,
|
|
this.deviceType,
|
|
this.connectWay,
|
|
this.rssi,
|
|
this.rawAdv,
|
|
this.advFlags,
|
|
this.manufacturerCompanyId,
|
|
this.manufacturerData,
|
|
this.serviceUuids = const [],
|
|
this.advRecords = const [],
|
|
});
|
|
|
|
final String name;
|
|
final String address;
|
|
final String? edrAddr;
|
|
final int? deviceType;
|
|
final int? connectWay;
|
|
final int? rssi;
|
|
|
|
/// 原始广播包 hex(不含 "0x" 前缀)。
|
|
final String? rawAdv;
|
|
|
|
/// AD Type 0x01 Flags 字节值。
|
|
final int? advFlags;
|
|
|
|
/// AD Type 0xFF 厂商数据的前两字节(小端)解析出的 Company ID。
|
|
final int? manufacturerCompanyId;
|
|
|
|
/// AD Type 0xFF 厂商数据去掉 Company ID 后剩余字节的 hex(不含 "0x")。
|
|
final String? manufacturerData;
|
|
|
|
/// ScanRecord.serviceUuids 列表(大写字符串)。
|
|
final List<String> serviceUuids;
|
|
|
|
/// 按 BLE Core Spec §11 解析的每条 AD 记录:{len, type, data}。
|
|
final List<Map<String, String>> advRecords;
|
|
|
|
factory JieliDevice.fromMap(Map<dynamic, dynamic> m) => JieliDevice(
|
|
name: (m['name'] as String?) ?? '',
|
|
address: m['address'] as String,
|
|
edrAddr: m['edrAddr'] as String?,
|
|
deviceType: m['deviceType'] as int?,
|
|
connectWay: m['connectWay'] as int?,
|
|
rssi: m['rssi'] as int?,
|
|
rawAdv: m['rawAdv'] as String?,
|
|
advFlags: m['advFlags'] as int?,
|
|
manufacturerCompanyId: m['manufacturerCompanyId'] as int?,
|
|
manufacturerData: m['manufacturerData'] as String?,
|
|
serviceUuids:
|
|
(m['serviceUuids'] as List?)?.cast<String>() ?? const [],
|
|
advRecords: (m['advRecords'] as List?)
|
|
?.whereType<Map>()
|
|
.map((e) => e.map(
|
|
(k, v) => MapEntry(k.toString(), v?.toString() ?? ''),
|
|
))
|
|
.toList() ??
|
|
const [],
|
|
);
|
|
|
|
@override
|
|
String toString() => 'JieliDevice($name, $address, rssi=$rssi)';
|
|
}
|
|
|
|
abstract class JieliEvent {
|
|
const JieliEvent();
|
|
}
|
|
|
|
class AdapterStatusEvent extends JieliEvent {
|
|
final bool enabled;
|
|
final bool hasBle;
|
|
const AdapterStatusEvent(this.enabled, this.hasBle);
|
|
}
|
|
|
|
class ScanStatusEvent extends JieliEvent {
|
|
final bool ble;
|
|
final bool started;
|
|
const ScanStatusEvent(this.ble, this.started);
|
|
}
|
|
|
|
class DeviceFoundEvent extends JieliEvent {
|
|
final JieliDevice device;
|
|
const DeviceFoundEvent(this.device);
|
|
}
|
|
|
|
class BondStatusEvent extends JieliEvent {
|
|
final String address;
|
|
final int status;
|
|
const BondStatusEvent(this.address, this.status);
|
|
}
|
|
|
|
class RcspInitEvent extends JieliEvent {
|
|
final String address;
|
|
final int code;
|
|
const RcspInitEvent(this.address, this.code);
|
|
bool get success => code == 0;
|
|
}
|
|
|
|
class ConnectionStateEvent extends JieliEvent {
|
|
final String address;
|
|
final int state;
|
|
const ConnectionStateEvent(this.address, this.state);
|
|
|
|
static const int connectionOk = 1;
|
|
static const int connectionConnecting = 2;
|
|
static const int connectionDisconnect = 0;
|
|
}
|
|
|
|
class UnknownJieliEvent extends JieliEvent {
|
|
final Map<dynamic, dynamic> raw;
|
|
const UnknownJieliEvent(this.raw);
|
|
}
|
|
|
|
// ───── 设备级事件 ─────
|
|
|
|
class BatteryEvent extends JieliEvent {
|
|
final String? address;
|
|
final int? level;
|
|
const BatteryEvent(this.address, this.level);
|
|
}
|
|
|
|
class PhoneCallStatusEvent extends JieliEvent {
|
|
final String? address;
|
|
final int status;
|
|
const PhoneCallStatusEvent(this.address, this.status);
|
|
}
|
|
|
|
class ExpandFunctionEvent extends JieliEvent {
|
|
final String? address;
|
|
final int opCode;
|
|
final String? payloadBase64;
|
|
const ExpandFunctionEvent(this.address, this.opCode, this.payloadBase64);
|
|
}
|
|
|
|
// ───── 翻译事件 ─────
|
|
|
|
class TranslationModeIds {
|
|
static const int idle = 0;
|
|
static const int record = 1;
|
|
static const int recordingTranslation = 2;
|
|
static const int callTranslation = 3;
|
|
static const int audioTranslation = 4;
|
|
static const int faceToFaceTranslation = 5;
|
|
static const int callTranslationWithStereo = 6;
|
|
/// MODE_CALL_RECORD:通话录音专用 mode(与 Android `TranslationMode.MODE_CALL_RECORD`=7 对齐)。
|
|
/// - 耳机一路推双声道交织 OPUS,解码后以 `TranslationStreams.inStereo` 单路抛出
|
|
/// - 没有 output 流,不接 TTS 回写
|
|
static const int callRecord = 7;
|
|
}
|
|
|
|
class TranslationStreams {
|
|
static const String inMic = 'in.mic';
|
|
static const String inUplink = 'in.uplink';
|
|
static const String inDownlink = 'in.downlink';
|
|
static const String inAudioFile = 'in.audioFile';
|
|
/// 双声道交织 PCM:L=本端 / R=对端(用于 MODE_CALL_RECORD / 设备录音通路)。
|
|
static const String inStereo = 'in.stereo';
|
|
static const String outSpeaker = 'out.speaker';
|
|
static const String outUplink = 'out.uplink';
|
|
static const String outDownlink = 'out.downlink';
|
|
static const String outLocalPlayback = 'out.localPlayback';
|
|
}
|
|
|
|
class TranslationAudioEvent extends JieliEvent {
|
|
final int modeId;
|
|
final String streamId;
|
|
final int sampleRate;
|
|
final int channels;
|
|
final int bitsPerSample;
|
|
final int seq;
|
|
final int tsMs;
|
|
final bool isFinal;
|
|
|
|
/// 16bit signed PCM 字节流(小端,interleaved 当 channels>1)
|
|
final Uint8List pcm;
|
|
|
|
const TranslationAudioEvent({
|
|
required this.modeId,
|
|
required this.streamId,
|
|
required this.sampleRate,
|
|
required this.channels,
|
|
required this.bitsPerSample,
|
|
required this.seq,
|
|
required this.tsMs,
|
|
required this.isFinal,
|
|
required this.pcm,
|
|
});
|
|
}
|
|
|
|
class TranslationLogEvent extends JieliEvent {
|
|
final int modeId;
|
|
final String content;
|
|
const TranslationLogEvent(this.modeId, this.content);
|
|
}
|
|
|
|
class TranslationResultEvent extends JieliEvent {
|
|
final int modeId;
|
|
final String? srcLang;
|
|
final String? srcText;
|
|
final String? destLang;
|
|
final String? destText;
|
|
/// 由调用方贯穿同一段翻译,便于字幕聚合到具体段落
|
|
final String? requestId;
|
|
const TranslationResultEvent({
|
|
required this.modeId,
|
|
this.srcLang, this.srcText, this.destLang, this.destText,
|
|
this.requestId,
|
|
});
|
|
}
|
|
|
|
class TranslationErrorEvent extends JieliEvent {
|
|
final int modeId;
|
|
final int code;
|
|
final String? message;
|
|
const TranslationErrorEvent(this.modeId, this.code, this.message);
|
|
}
|
|
|
|
// ───── 语音助手 / 唤醒事件 ─────
|
|
|
|
class SpeechStartEvent extends JieliEvent {
|
|
final String? address;
|
|
/// RecordParam.VOICE_TYPE_PCM=0 / SPEEX=1 / OPUS=2
|
|
final int? voiceType;
|
|
/// 已转换为 Hz(如 16000)
|
|
final int? sampleRate;
|
|
/// VAD_WAY_DEVICE=0 / SDK=1
|
|
final int? vadWay;
|
|
final int tsMs;
|
|
const SpeechStartEvent({
|
|
this.address,
|
|
this.voiceType, this.sampleRate, this.vadWay,
|
|
required this.tsMs,
|
|
});
|
|
}
|
|
|
|
class SpeechAudioEvent extends JieliEvent {
|
|
final String? address;
|
|
/// 'pcm16' / 'speex'(speex 时未解码)
|
|
final String encoding;
|
|
final int sampleRate;
|
|
final int channels;
|
|
final int bitsPerSample;
|
|
final int tsMs;
|
|
/// 16bit signed PCM(encoding=='pcm16');speex 时为原始帧
|
|
final Uint8List pcm;
|
|
const SpeechAudioEvent({
|
|
this.address,
|
|
required this.encoding,
|
|
required this.sampleRate,
|
|
required this.channels,
|
|
required this.bitsPerSample,
|
|
required this.tsMs,
|
|
required this.pcm,
|
|
});
|
|
}
|
|
|
|
class SpeechEndEvent extends JieliEvent {
|
|
final String? address;
|
|
final int? reason;
|
|
final String? message;
|
|
final int tsMs;
|
|
const SpeechEndEvent({this.address, this.reason, this.message, required this.tsMs});
|
|
}
|
|
|
|
class SpeechErrorEvent extends JieliEvent {
|
|
final int code;
|
|
final String? message;
|
|
const SpeechErrorEvent(this.code, this.message);
|
|
}
|
|
|
|
class SpeechVoiceType {
|
|
static const int pcm = 0;
|
|
static const int speex = 1;
|
|
static const int opus = 2;
|
|
}
|
|
|
|
class SpeechSampleRate {
|
|
static const int khz8 = 8;
|
|
static const int khz16 = 16;
|
|
}
|
|
|
|
class SpeechVadWay {
|
|
static const int device = 0;
|
|
static const int sdk = 1;
|
|
}
|
|
|
|
class SpeechStopReason {
|
|
static const int normal = 0;
|
|
static const int stop = 1;
|
|
}
|
|
|
|
// ───── 设备录音事件 ─────
|
|
|
|
/// 通道 ID 常量(与 Kotlin DeviceRecordFeature 保持一致)
|
|
///
|
|
/// 当前实现走 MODE_CALL_TRANSLATION_WITH_STEREO + ch=2,耳机一次推一帧
|
|
/// **双声道交织 PCM**(L=本端 / R=对端),统一用 [inStereo] 标识。
|
|
class DeviceRecordStreams {
|
|
static const String inStereo = 'in.stereo';
|
|
}
|
|
|
|
class DeviceRecordStartEvent extends JieliEvent {
|
|
final String? address;
|
|
final int sampleRate;
|
|
final int tsMs;
|
|
const DeviceRecordStartEvent({
|
|
this.address,
|
|
required this.sampleRate,
|
|
required this.tsMs,
|
|
});
|
|
}
|
|
|
|
class DeviceRecordAudioEvent extends JieliEvent {
|
|
final String? address;
|
|
|
|
/// [DeviceRecordStreams.inStereo](双声道交织:L=本端 / R=对端)
|
|
final String streamId;
|
|
final int sampleRate;
|
|
final int channels;
|
|
final int bitsPerSample;
|
|
final int tsMs;
|
|
|
|
/// 16bit signed PCM 字节流(小端)
|
|
final Uint8List pcm;
|
|
|
|
const DeviceRecordAudioEvent({
|
|
this.address,
|
|
required this.streamId,
|
|
required this.sampleRate,
|
|
required this.channels,
|
|
required this.bitsPerSample,
|
|
required this.tsMs,
|
|
required this.pcm,
|
|
});
|
|
}
|
|
|
|
class DeviceRecordStopEvent extends JieliEvent {
|
|
final String? address;
|
|
final int tsMs;
|
|
const DeviceRecordStopEvent({this.address, required this.tsMs});
|
|
}
|
|
|
|
class DeviceRecordErrorEvent extends JieliEvent {
|
|
final String? address;
|
|
final int code;
|
|
final String? message;
|
|
const DeviceRecordErrorEvent({this.address, required this.code, this.message});
|
|
}
|
|
|
|
// ───── AI 助理通路事件(JieliAssistantPort:MODE_RECORD + DEVICE_ALWAYS_RECORDING)─────
|
|
// 与 SpeechXxxEvent 互斥;走 RCSP 翻译模式状态机,耳机端 OPUS → 16k PCM 自动持续上推。
|
|
|
|
class AssistantStartEvent extends JieliEvent {
|
|
final int sampleRate;
|
|
final int tsMs;
|
|
const AssistantStartEvent({required this.sampleRate, required this.tsMs});
|
|
}
|
|
|
|
class AssistantAudioEvent extends JieliEvent {
|
|
/// 'pcm16'
|
|
final String encoding;
|
|
final int sampleRate;
|
|
final int channels;
|
|
final int bitsPerSample;
|
|
final int sequence;
|
|
final int tsMs;
|
|
/// 16-bit signed PCM, mono, 16kHz, 20ms 帧
|
|
final Uint8List pcm;
|
|
const AssistantAudioEvent({
|
|
required this.encoding,
|
|
required this.sampleRate,
|
|
required this.channels,
|
|
required this.bitsPerSample,
|
|
required this.sequence,
|
|
required this.tsMs,
|
|
required this.pcm,
|
|
});
|
|
}
|
|
|
|
class AssistantEndEvent extends JieliEvent {
|
|
final int tsMs;
|
|
const AssistantEndEvent({required this.tsMs});
|
|
}
|
|
|
|
class AssistantErrorEvent extends JieliEvent {
|
|
final String code;
|
|
final String? message;
|
|
const AssistantErrorEvent(this.code, this.message);
|
|
}
|
|
|
|
// ───── OTA 事件 ─────
|
|
|
|
enum OtaState {
|
|
idle, inquiring, notifyingSize, entering, transferring, verifying, rebooting, done, failed, cancelled,
|
|
}
|
|
|
|
class OtaStateEvent extends JieliEvent {
|
|
final OtaState state;
|
|
final int sent;
|
|
final int total;
|
|
/// -1 表示未知(idle/cancelled 等)
|
|
final int percent;
|
|
final int tsMs;
|
|
const OtaStateEvent({
|
|
required this.state, required this.sent, required this.total,
|
|
required this.percent, required this.tsMs,
|
|
});
|
|
}
|
|
|
|
class OtaErrorEvent extends JieliEvent {
|
|
final int code;
|
|
final String? message;
|
|
const OtaErrorEvent(this.code, this.message);
|
|
}
|
|
|
|
class Jielihome {
|
|
Jielihome._();
|
|
static final Jielihome instance = Jielihome._();
|
|
|
|
Stream<JieliEvent>? _events;
|
|
|
|
Stream<JieliEvent> get events {
|
|
return _events ??= _eventChannel
|
|
.receiveBroadcastStream()
|
|
.map<JieliEvent>((raw) => _parseEvent(raw as Map));
|
|
}
|
|
|
|
JieliEvent _parseEvent(Map raw) {
|
|
switch (raw['type']) {
|
|
case 'adapterStatus':
|
|
return AdapterStatusEvent(raw['enabled'] == true, raw['hasBle'] == true);
|
|
case 'scanStatus':
|
|
return ScanStatusEvent(raw['ble'] == true, raw['started'] == true);
|
|
case 'deviceFound':
|
|
return DeviceFoundEvent(JieliDevice.fromMap(raw));
|
|
case 'bondStatus':
|
|
return BondStatusEvent(raw['address'] as String, raw['status'] as int);
|
|
case 'rcspInit':
|
|
return RcspInitEvent(raw['address'] as String, raw['code'] as int);
|
|
case 'connectionState':
|
|
return ConnectionStateEvent(raw['address'] as String, raw['state'] as int);
|
|
case 'battery':
|
|
return BatteryEvent(raw['address'] as String?, raw['level'] as int?);
|
|
case 'phoneCallStatus':
|
|
return PhoneCallStatusEvent(raw['address'] as String?, (raw['status'] as int?) ?? 0);
|
|
case 'expandFunction':
|
|
return ExpandFunctionEvent(
|
|
raw['address'] as String?,
|
|
(raw['opCode'] as int?) ?? 0,
|
|
raw['payloadBase64'] as String?,
|
|
);
|
|
case 'translationAudio':
|
|
return TranslationAudioEvent(
|
|
modeId: (raw['modeId'] as int?) ?? 0,
|
|
streamId: raw['streamId'] as String,
|
|
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
|
|
channels: (raw['channels'] as int?) ?? 1,
|
|
bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16,
|
|
seq: (raw['seq'] as num?)?.toInt() ?? 0,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
isFinal: raw['final'] == true,
|
|
pcm: raw['pcm'] as Uint8List,
|
|
);
|
|
case 'translationLog':
|
|
return TranslationLogEvent(
|
|
(raw['modeId'] as int?) ?? 0,
|
|
raw['content'] as String? ?? '',
|
|
);
|
|
case 'translationResult':
|
|
return TranslationResultEvent(
|
|
modeId: (raw['modeId'] as int?) ?? 0,
|
|
srcLang: raw['srcLang'] as String?,
|
|
srcText: raw['srcText'] as String?,
|
|
destLang: raw['destLang'] as String?,
|
|
destText: raw['destText'] as String?,
|
|
requestId: raw['requestId'] as String?,
|
|
);
|
|
case 'translationError':
|
|
return TranslationErrorEvent(
|
|
(raw['modeId'] as int?) ?? 0,
|
|
(raw['code'] as int?) ?? 0,
|
|
raw['message'] as String?,
|
|
);
|
|
case 'speechStart':
|
|
return SpeechStartEvent(
|
|
address: raw['address'] as String?,
|
|
voiceType: raw['voiceType'] as int?,
|
|
sampleRate: raw['sampleRate'] as int?,
|
|
vadWay: raw['vadWay'] as int?,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
);
|
|
case 'speechAudio':
|
|
return SpeechAudioEvent(
|
|
address: raw['address'] as String?,
|
|
encoding: (raw['encoding'] as String?) ?? 'pcm16',
|
|
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
|
|
channels: (raw['channels'] as int?) ?? 1,
|
|
bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
pcm: raw['pcm'] as Uint8List,
|
|
);
|
|
case 'speechEnd':
|
|
return SpeechEndEvent(
|
|
address: raw['address'] as String?,
|
|
reason: raw['reason'] as int?,
|
|
message: raw['message'] as String?,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
);
|
|
case 'speechError':
|
|
return SpeechErrorEvent(
|
|
(raw['code'] as int?) ?? 0,
|
|
raw['message'] as String?,
|
|
);
|
|
case 'assistantStart':
|
|
return AssistantStartEvent(
|
|
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
);
|
|
case 'assistantAudio':
|
|
return AssistantAudioEvent(
|
|
encoding: (raw['encoding'] as String?) ?? 'pcm16',
|
|
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
|
|
channels: (raw['channels'] as int?) ?? 1,
|
|
bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16,
|
|
sequence: (raw['sequence'] as num?)?.toInt() ?? 0,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
pcm: raw['pcm'] as Uint8List,
|
|
);
|
|
case 'assistantEnd':
|
|
return AssistantEndEvent(
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
);
|
|
case 'assistantError':
|
|
return AssistantErrorEvent(
|
|
(raw['code'] as String?) ?? 'device.assistant.unknown',
|
|
raw['message'] as String?,
|
|
);
|
|
case 'deviceRecordStart':
|
|
return DeviceRecordStartEvent(
|
|
address: raw['address'] as String?,
|
|
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
);
|
|
case 'deviceRecordAudio':
|
|
return DeviceRecordAudioEvent(
|
|
address: raw['address'] as String?,
|
|
streamId: raw['streamId'] as String,
|
|
sampleRate: (raw['sampleRate'] as int?) ?? 16000,
|
|
channels: (raw['channels'] as int?) ?? 1,
|
|
bitsPerSample: (raw['bitsPerSample'] as int?) ?? 16,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
pcm: raw['pcm'] as Uint8List,
|
|
);
|
|
case 'deviceRecordStop':
|
|
return DeviceRecordStopEvent(
|
|
address: raw['address'] as String?,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
);
|
|
case 'deviceRecordError':
|
|
return DeviceRecordErrorEvent(
|
|
address: raw['address'] as String?,
|
|
code: (raw['code'] as int?) ?? 0,
|
|
message: raw['message'] as String?,
|
|
);
|
|
case 'otaState':
|
|
return OtaStateEvent(
|
|
state: _parseOtaState(raw['state'] as String?),
|
|
sent: (raw['sent'] as num?)?.toInt() ?? -1,
|
|
total: (raw['total'] as num?)?.toInt() ?? 0,
|
|
percent: (raw['percent'] as int?) ?? -1,
|
|
tsMs: (raw['tsMs'] as num?)?.toInt() ?? 0,
|
|
);
|
|
case 'otaError':
|
|
return OtaErrorEvent(
|
|
(raw['code'] as int?) ?? 0,
|
|
raw['message'] as String?,
|
|
);
|
|
default:
|
|
return UnknownJieliEvent(raw);
|
|
}
|
|
}
|
|
|
|
OtaState _parseOtaState(String? s) {
|
|
switch (s) {
|
|
case 'INQUIRING': return OtaState.inquiring;
|
|
case 'NOTIFYING_SIZE': return OtaState.notifyingSize;
|
|
case 'ENTERING': return OtaState.entering;
|
|
case 'TRANSFERRING': return OtaState.transferring;
|
|
case 'VERIFYING': return OtaState.verifying;
|
|
case 'REBOOTING': return OtaState.rebooting;
|
|
case 'DONE': return OtaState.done;
|
|
case 'FAILED': return OtaState.failed;
|
|
case 'CANCELLED': return OtaState.cancelled;
|
|
default: return OtaState.idle;
|
|
}
|
|
}
|
|
|
|
Future<String?> getPlatformVersion() async {
|
|
return await _methodChannel.invokeMethod<String>('getPlatformVersion');
|
|
}
|
|
|
|
Future<void> initialize({
|
|
bool multiDevice = true,
|
|
bool skipNoNameDev = false,
|
|
bool enableLog = false,
|
|
}) async {
|
|
await _methodChannel.invokeMethod<bool>('initialize', {
|
|
'multiDevice': multiDevice,
|
|
'skipNoNameDev': skipNoNameDev,
|
|
'enableLog': enableLog,
|
|
});
|
|
}
|
|
|
|
/// 启动扫描,可选复合过滤。
|
|
///
|
|
/// - [nameList]:设备名命中列表(精确匹配,忽略大小写);空 = 不按名过滤
|
|
/// - [uuidList]:UUID 命中列表 → `ScanFilter.setServiceUuid`;空 = 不按 UUID 过滤
|
|
/// - [skipUnnamed]:跳过没有 name 的广播(环境噪声),默认 true
|
|
Future<void> startScan({
|
|
Duration timeout = const Duration(seconds: 30),
|
|
List<String> nameList = const [],
|
|
List<String> uuidList = const [],
|
|
bool skipUnnamed = true,
|
|
}) async {
|
|
await _methodChannel.invokeMethod<bool>('startScan', {
|
|
'timeoutMs': timeout.inMilliseconds,
|
|
'nameList': nameList,
|
|
'uuidList': uuidList,
|
|
'skipUnnamed': skipUnnamed,
|
|
});
|
|
}
|
|
|
|
Future<void> stopScan() => _methodChannel.invokeMethod('stopScan');
|
|
|
|
Future<bool> isScanning() async =>
|
|
(await _methodChannel.invokeMethod<bool>('isScanning')) ?? false;
|
|
|
|
Future<void> connect(JieliDevice device) async {
|
|
await _methodChannel.invokeMethod<bool>('connect', {
|
|
'address': device.address,
|
|
'edrAddr': device.edrAddr,
|
|
'connectWay': device.connectWay,
|
|
'deviceType': device.deviceType,
|
|
});
|
|
}
|
|
|
|
Future<void> disconnect(String address) async {
|
|
await _methodChannel.invokeMethod<bool>('disconnect', {'address': address});
|
|
}
|
|
|
|
Future<bool> isConnected(String address) async =>
|
|
(await _methodChannel.invokeMethod<bool>('isConnected', {'address': address})) ??
|
|
false;
|
|
|
|
Future<JieliDevice?> connectedDevice() async {
|
|
final raw = await _methodChannel.invokeMethod<Map?>('connectedDevice');
|
|
if (raw == null) return null;
|
|
return JieliDevice(
|
|
name: (raw['name'] as String?) ?? '',
|
|
address: raw['address'] as String,
|
|
);
|
|
}
|
|
|
|
// ───── 设备信息查询 ─────
|
|
|
|
Future<Map<String, Object?>?> deviceSnapshot(String address) async {
|
|
final raw = await _methodChannel.invokeMethod<Map?>(
|
|
'deviceSnapshot', {'address': address});
|
|
return raw?.cast<String, Object?>();
|
|
}
|
|
|
|
Future<Map<String, Object?>?> queryTargetInfo(String address, {int mask = 0x0F}) async {
|
|
final raw = await _methodChannel.invokeMethod<Map?>(
|
|
'queryTargetInfo', {'address': address, 'mask': mask});
|
|
return raw?.cast<String, Object?>();
|
|
}
|
|
|
|
// ───── 自定义指令 ─────
|
|
|
|
Future<List<int>?> sendCustomCmd(String address, int opCode, List<int> payload) async {
|
|
final raw = await _methodChannel.invokeMethod<List<dynamic>?>(
|
|
'sendCustomCmd',
|
|
{'address': address, 'opCode': opCode, 'payload': payload},
|
|
);
|
|
return raw?.cast<int>();
|
|
}
|
|
|
|
// ───── 翻译 ─────
|
|
|
|
Future<void> startTranslation({
|
|
required int modeId,
|
|
Map<String, Object?> args = const {},
|
|
}) async {
|
|
await _methodChannel.invokeMethod<bool>('startTranslation', {
|
|
'modeId': modeId,
|
|
'args': args,
|
|
});
|
|
}
|
|
|
|
Future<void> stopTranslation() =>
|
|
_methodChannel.invokeMethod('stopTranslation');
|
|
|
|
Future<Map<String, Object?>?> translationStatus() async {
|
|
final raw = await _methodChannel.invokeMethod<Map?>('translationStatus');
|
|
return raw?.cast<String, Object?>();
|
|
}
|
|
|
|
/// 把外部翻译服务返回的 TTS PCM 喂回插件,由插件根据当前模式回送给耳机或本地播放。
|
|
/// [pcm] 必须是 16bit signed PCM(小端、interleaved when channels>1)。
|
|
/// 推荐传 [Uint8List];也接受 List<int> 但会触发一次拷贝。
|
|
Future<bool> feedTranslatedAudio({
|
|
required String streamId,
|
|
required List<int> pcm,
|
|
int sampleRate = 16000,
|
|
int channels = 1,
|
|
int bitsPerSample = 16,
|
|
bool isFinal = false,
|
|
}) async {
|
|
final bytes = pcm is Uint8List ? pcm : Uint8List.fromList(pcm);
|
|
return (await _methodChannel.invokeMethod<bool>('feedTranslatedAudio', {
|
|
'streamId': streamId,
|
|
'pcm': bytes,
|
|
'sampleRate': sampleRate,
|
|
'channels': channels,
|
|
'bitsPerSample': bitsPerSample,
|
|
'final': isFinal,
|
|
})) ??
|
|
false;
|
|
}
|
|
|
|
/// 透传翻译文本(字幕)到 EventChannel,供 UI 订阅 [TranslationResultEvent]。
|
|
/// [requestId] 用于把同一段翻译的多次回调串起来。
|
|
Future<void> feedTranslationResult({
|
|
String? srcLang, String? srcText,
|
|
String? destLang, String? destText,
|
|
String? requestId,
|
|
}) async {
|
|
await _methodChannel.invokeMethod('feedTranslationResult', {
|
|
'srcLang': srcLang,
|
|
'srcText': srcText,
|
|
'destLang': destLang,
|
|
'destText': destText,
|
|
'requestId': requestId,
|
|
});
|
|
}
|
|
|
|
/// 探测当前已连耳机是否支持立体声通话翻译方案。
|
|
/// [address] 不传则用当前已连设备。
|
|
Future<bool> isSupportCallTranslationWithStereo({String? address}) async {
|
|
return (await _methodChannel.invokeMethod<bool>(
|
|
'isSupportCallTranslationWithStereo',
|
|
{'address': address},
|
|
)) ??
|
|
false;
|
|
}
|
|
|
|
/// 把外部音频文件解码后的 PCM 灌入「音视频翻译」模式(仅 MODE_AUDIO_TRANSLATION 生效)。
|
|
Future<bool> feedAudioFilePcm({
|
|
required List<int> pcm,
|
|
int sampleRate = 16000,
|
|
}) async {
|
|
final bytes = pcm is Uint8List ? pcm : Uint8List.fromList(pcm);
|
|
return (await _methodChannel.invokeMethod<bool>('feedAudioFilePcm', {
|
|
'pcm': bytes,
|
|
'sampleRate': sampleRate,
|
|
})) ??
|
|
false;
|
|
}
|
|
|
|
// ───── 语音助手 / 唤醒 ─────
|
|
// 提示:插件初始化时已经常驻订阅了耳机 RECORD 状态回调;
|
|
// - 耳机本地检测到唤醒词或按键 → 自动收到 SpeechStart/SpeechAudio/SpeechEnd 事件
|
|
// - 业务层不需要先调任何方法
|
|
// 下面的 speechStart/Stop 仅用于「APP 主动触发/取消」语音助手的场景。
|
|
|
|
Future<bool> speechIsRecording({String? address}) async =>
|
|
(await _methodChannel.invokeMethod<bool>('speechIsRecording', {'address': address})) ??
|
|
false;
|
|
|
|
Future<void> speechStart({
|
|
String? address,
|
|
int voiceType = SpeechVoiceType.opus,
|
|
int sampleRate = SpeechSampleRate.khz16,
|
|
int vadWay = SpeechVadWay.device,
|
|
}) async {
|
|
await _methodChannel.invokeMethod<bool>('speechStart', {
|
|
'address': address,
|
|
'voiceType': voiceType,
|
|
'sampleRate': sampleRate,
|
|
'vadWay': vadWay,
|
|
});
|
|
}
|
|
|
|
Future<void> speechStop({
|
|
String? address,
|
|
int reason = SpeechStopReason.normal,
|
|
}) async {
|
|
await _methodChannel.invokeMethod<bool>('speechStop', {
|
|
'address': address,
|
|
'reason': reason,
|
|
});
|
|
}
|
|
|
|
// ───── AI 助理通路(JieliAssistantPort)─────
|
|
// 与 [speechStart] 不同:本通路走 RCSP `MODE_RECORD` +
|
|
// `STRATEGY_DEVICE_ALWAYS_RECORDING`,耳机自主持续推 OPUS,APP 不需要唤醒词
|
|
// 也不需要按键。Port 内部以 `packetSize=40` 解码 16k OPUS 帧,避免 SpeechFeature
|
|
// 路径下因 packetSize=200 导致的 80% 帧丢失。
|
|
//
|
|
// 订阅 [AssistantAudioEvent] 拿 16k/16bit/mono/20ms PCM 帧;
|
|
// [AssistantErrorEvent] / [AssistantEndEvent] 用于错误和退出通知。
|
|
Future<bool> assistantStart() async =>
|
|
(await _methodChannel.invokeMethod<bool>('assistantStart')) ?? false;
|
|
|
|
Future<void> assistantStop() async {
|
|
await _methodChannel.invokeMethod<bool>('assistantStop');
|
|
}
|
|
|
|
Future<bool> assistantIsRunning() async =>
|
|
(await _methodChannel.invokeMethod<bool>('assistantIsRunning')) ?? false;
|
|
|
|
// ───── 设备录音 ─────
|
|
// 订阅 [DeviceRecordStartEvent] / [DeviceRecordAudioEvent] /
|
|
// [DeviceRecordStopEvent] / [DeviceRecordErrorEvent] 接收录音状态和 PCM 数据。
|
|
|
|
/// 启动设备录音上行。
|
|
/// [address] 省略则取当前已连设备。
|
|
Future<void> deviceRecordStart({
|
|
String? address,
|
|
int sampleRate = 16000,
|
|
}) async {
|
|
await _methodChannel.invokeMethod<bool>('deviceRecordStart', {
|
|
'args': {
|
|
if (address != null) 'address': address,
|
|
'sampleRate': sampleRate,
|
|
},
|
|
});
|
|
}
|
|
|
|
Future<void> deviceRecordStop() =>
|
|
_methodChannel.invokeMethod('deviceRecordStop');
|
|
|
|
Future<Map<String, Object?>?> deviceRecordStatus() async {
|
|
final raw = await _methodChannel.invokeMethod<Map?>('deviceRecordStatus');
|
|
return raw?.cast<String, Object?>();
|
|
}
|
|
|
|
// ───── OTA ─────
|
|
|
|
/// 启动 OTA 升级。订阅 [OtaStateEvent] / [OtaErrorEvent] 看进度。
|
|
/// [firmwareFilePath] 必须是手机本地完整路径(你提前下好的固件文件)。
|
|
Future<void> otaStart({
|
|
String? address,
|
|
required String firmwareFilePath,
|
|
int blockSize = 512,
|
|
List<int>? fileFlag,
|
|
}) async {
|
|
await _methodChannel.invokeMethod<bool>('otaStart', {
|
|
'address': address,
|
|
'firmwareFilePath': firmwareFilePath,
|
|
'blockSize': blockSize,
|
|
if (fileFlag != null)
|
|
'fileFlag': fileFlag is Uint8List ? fileFlag : Uint8List.fromList(fileFlag),
|
|
});
|
|
}
|
|
|
|
Future<void> otaCancel() => _methodChannel.invokeMethod('otaCancel');
|
|
|
|
Future<bool> otaIsRunning() async =>
|
|
(await _methodChannel.invokeMethod<bool>('otaIsRunning')) ?? false;
|
|
}
|
|
|