diff --git a/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNui.h b/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNui.h new file mode 100644 index 0000000..7956364 --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNui.h @@ -0,0 +1,210 @@ +#ifndef NeoNui_h +#define NeoNui_h + +#ifdef DEBUG_MODE +#define TLog( s, ... ) NSLog( s, ##__VA_ARGS__ ) +#else +#define TLog( s, ... ) +#endif + +#import +#import "NeoNuiCode.h" + +typedef int NuiResultCode; + +enum NuiAudioState { + STATE_OPEN, + STATE_PAUSE, + STATE_CLOSE, +}; + +enum NuiFunMode { + kFuncModeAsp = 0x00000001, + kFuncModeVad = 0x00000010, + kFuncModeKws = 0x00000100, + kFuncModeAsr = 0x00001000, + kFuncModeEnc = 0x00010000, + kFuncModeOu = 0x00100000, + kFuncModeFull = 0x11111111, +}; + +enum NuiAudioExtraEvent { + AudioRmsChanged, +}; + +enum NuiCallbackEvent { + EVENT_VAD_START, + EVENT_VAD_TIMEOUT, + EVENT_VAD_END, + EVENT_WUW, + EVENT_WUW_TRUSTED, + EVENT_WUW_CONFIRMED, + EVENT_WUW_REJECTED, + EVENT_WUW_END, + EVENT_ASR_PARTIAL_RESULT, + EVENT_ASR_RESULT, + EVENT_ASR_ERROR, // 10 + EVENT_DIALOG_ERROR, + EVENT_ONESHOT_TIMEOUT, + EVENT_DIALOG_RESULT, + EVENT_WUW_HINT, + EVENT_VPR_RESULT, + EVENT_TEXT2ACTION_DIALOG_RESULT, + EVENT_TEXT2ACTION_ERROR, + EVENT_ATTR_RESULT, + EVENT_MIC_ERROR, + EVENT_DIALOG_EX, // 20 + EVENT_WUW_ERROR, + EVENT_BEFORE_CONNECTION, + EVENT_SENTENCE_START, + EVENT_SENTENCE_END, + EVENT_SENTENCE_SEMANTICS, + EVENT_RESULT_TRANSLATED, + EVENT_TRANSCRIBER_COMPLETE, + EVENT_FILE_TRANS_CONNECTED, + EVENT_FILE_TRANS_UPLOADED, + EVENT_FILE_TRANS_RESULT, // 30 + EVENT_FILE_TRANS_UPLOAD_PROGRESS, + EVENT_TRANSCRIBER_STARTED, + EVENT_ASR_STARTED, + EVENT_FILE_TRANS_QUERY_RESULT, + EVENT_WUW_START, + EVENT_WUW_DATA, + EVENT_ASR_TTS_DATA, + EVENT_ASR_TTS_START, + EVENT_ASR_TTS_COMPLETE, + EVENT_RESULT_TRANSLATED_END, + EVENT_ASR_WARN, // 41 +}; + +enum NuiVadMode { + MODE_VAD, + MODE_P2T, + MODE_KWS, + MODE_PARALLEL, + MODE_KWS2PARALLEL, + MODE_AUTO_CONTINUAL, + MODE_KWS_CONTINUAL, + MODE_KWS2TALK, + MODE_ONLY_KWS +}; + +enum ServiceType { + SERVICE_TYPE_NONE = -1, + SERVICE_TYPE_ASR = 0, // SpeechRecognizer + SERVICE_TYPE_TIANGONG_ASSISTANT = 1, + SERVICE_TYPE_DIALOG_ASSISTANT = 2, //DialogAssistant + SERVICE_TYPE_VIRTUAL_ASSISTANT = 3, //TiangongV4 + SERVICE_TYPE_SPEECH_TRANSCRIBER = 4 //SpeechTranscriber +}; + +enum NuiSdkVprEvent { + EVENT_VPR_NONE, + EVENT_VPR_REGISTER_START, + EVENT_VPR_REGISTER_DONE, + EVENT_VPR_REGISTER_FAILED, + EVENT_VPR_UPDATE_START, + EVENT_VPR_UPDATE_DONE, + EVENT_VPR_UPDATE_FAIL, + EVENT_VPR_DELETE_DONE, + EVENT_VPR_DELETE_FAIL +}; + +typedef enum NuiSdkVprEvent NuiSdkVprEvent; +typedef enum NuiAudioState NuiAudioState; +typedef enum NuiCallbackEvent NuiCallbackEvent; +typedef enum NuiCallbackEvent NuiCallbackEvent; +typedef enum NuiVadMode NuiVadMode; + +@protocol NeoNuiSdkDelegate +@optional +-(int) onNuiNeedAudioData:(char *)audioData length:(int)len; +-(void) onNuiAudioStateChanged:(NuiAudioState)state; +-(void) onNuiEventCallback:(NuiCallbackEvent)nuiEvent + dialog:(long)dialog + kwsResult:(const char *)wuw + asrResult:(const char *)asr_result + ifFinish:(BOOL)finish + retCode:(int)code; +-(void) onNuiRmsChanged:(float) rms; +-(void) onNuiAssistEventCallback:(NuiCallbackEvent)nuiEvent + info:(char*)info + infoLen:(int)info_len + buffer:(char*)buffer + len:(int)len; +-(void) onNuiLogTrackCallback:(NuiSdkLogLevel)level + logMessage:(const char *)log; + +-(void) onFileTransEventCallback:(NuiCallbackEvent)nuiEvent + asrResult:(const char *)asr_result + taskId:(const char *)task_id + ifFinish:(BOOL)finish + retCode:(int)code; +-(void) onFileTransLogTrackCallback:(NuiSdkLogLevel)level + logMessage:(const char *)log; +@end + +@interface NeoNui : NSObject + +@property (readonly) void* nui_sdk; +@property (nonatomic,weak) id delegate; + ++ (instancetype)get_instance; + +//Initialize API +-(NuiResultCode) nui_initialize:(const char *)parameters + logLevel:(NuiSdkLogLevel)level + saveLog:(BOOL)save_log; +//Release API +-(NuiResultCode) nui_release; + +//Dialog API +-(NuiResultCode) nui_dialog_start:(NuiVadMode)vad_mode + dialogParam:(const char *)dialog_params; +-(NuiResultCode) nui_dialog_cancel:(BOOL)force; +-(NuiResultCode) nui_dialog_resume; + +//Text to Action API +-(NuiResultCode) nui_dialog_text2action:(const char *)text + Context:(const char *)context + isNewDialog:(BOOL)is_new_dialog + dialogParams:(const char *)dialog_params; +-(NuiResultCode) nui_dialog_text2action_cancel; + +//File Transcriber API +-(NuiResultCode) nui_file_trans_start:(const char *)params + taskId:(char*)task_id; +-(NuiResultCode) nui_file_trans_cancel:(const char*)task_id; +-(NuiResultCode) nui_file_trans_query:(const char*)task_id; + +//Set parameter API +-(NuiResultCode) nui_set_param:(const char *)para + Value:(const char *)value; +-(NuiResultCode) nui_set_params:(const char *)params; + +-(const char *) nui_get_param:(const char *)param; + +-(NuiResultCode) nui_update_audio_data:(const char *)data + Len:(int)length + FirstPack:(BOOL)first_pack; +-(const char*) nui_get_version; +-(const char*) nui_get_all_response; + +#ifdef NUI_VPR +//Voice Print Recognition API +-(NuiResultCode) nui_vpr_register_user:(const char*)service_id + groupId:(const char * )group_id + userId:(const char * )user_id; +-(NuiResultCode) nui_vpr_update_user:(const char* )service_id + groupId:(const char * )group_id + userId:(const char * )user_id; +-(NuiResultCode) nui_vpr_delete_user:(const char* )service_id + groupId:(const char * )group_id + userId:(const char * )user_id; +-(NuiResultCode) nui_vpr_enable; +-(NuiResultCode) nui_vpr_disable; +-(NuiResultCode) nui_vpr_register_cancel; +#endif +@end + +#endif diff --git a/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiCode.h b/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiCode.h new file mode 100644 index 0000000..c57785f --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiCode.h @@ -0,0 +1,138 @@ +// +// NeoNuiCode.h +// +// +// Created by zhouguangdong on 2021/3/11. +// + +#ifndef NeoNuiCode_h +#define NeoNuiCode_h + +enum NuiSdkLogLevel { + NUI_LOG_LEVEL_VERBOSE, + NUI_LOG_LEVEL_DEBUG, + NUI_LOG_LEVEL_INFO, + NUI_LOG_LEVEL_WARNING, + NUI_LOG_LEVEL_ERROR, + NUI_LOG_LEVEL_NONE, +}; +typedef enum NuiSdkLogLevel NuiSdkLogLevel; + +typedef int NuiResultCode; +#define SUCCESS (0) +//legacy, not required handle +#define DEFAULT_ERROR (240999) //default err value. + +//config or params invalid +#define NUI_CONFIG_INVALID (240001) //Config or file invalid. +#define ILLEGAL_PARAM (240002) //parmeters illegal. +#define ILLEGAL_INIT_PARAM (240003) //initialize with illegal parameters. +#define NECESSARY_PARAM_LACK (240004) //lack of necessaray parameters. +#define NULL_PARAM_ERROR (240005) //parameters with null pointer or empty content. +#define NULL_LISTENER_ERROR (240006) //no listener for callback set. +#define NULL_DIALOG_ERROR (240007) //no dialog created. +#define NULL_ENGINE_ERROR (240008) //no dialog engine created. +#define ILLEGAL_DATA (240009) //transfer illegal audio or video data. +#define ILLEGAL_WORD_LIST (2400010) //word list format error +//state invalid +#define ILLEGAL_REENTRANT (240010) //happen when call from async callback or sdk already exit. +#define SDK_NOT_INIT (240011) //call apis when sdk not initialized. +#define SDK_ALREADY_INIT (240012) //call initialize if already initialized. +#define DIALOG_INVALID_STATE (240013) //dialog state machine error. +#define STATE_INVALID (240014) //asr engine state machine error. +#define ILLEGAL_FUNC_CALL (240015) //some function is not supposed to call on some working mode. + +//system call fail +#define MEM_ALLOC_ERROR (240020) //alloc or new object fail. +#define FILE_ACCESS_FAIL (240021) //access file fail. +#define CREATE_DIR_ERROR (240022) //create directory fail. + +//nui call fail +#define CREATE_NUI_ERROR (240030) //create dialog engine fail. +#define TEXT_DIALOG_START_FAIL (240031) //start text to action fail. +#define TEXT_DIALOG_CANCEL_FAIL (240032) //cancel text to action fail. +#define WUW_DUPLICATE (240033) //set duplicated wake words for dynamic keywords. + +//cei call fail +#define CEI_INIT_FAIL (240040) //init cei engine fail. +#define CEI_SET_PARAM_FAIL (240041) //cei set parameters fail. +#define CEI_COMPILE_GRAMMAR_FAIL (240042) //cei compile grammar fail. +#define CEI_STOP_FAIL (240043) //cei stop fail. +#define CEI_CANCEL_FAIL (240044) //cei cancel fail. +#define CEI_UNLOAD_KWS_FAIL (240045) //cei unload kws fail. +#define GET_WUW_ERROR (240046) //cei get wakeup word fail. +#define CEI_CHECK_BIN_FAIL (240047) //check resource bin file fail + +//audio manager +#define SELECT_RECORDER_ERROR (240050) //select recorder fail. +#define UPDATE_AUDIO_ERROR (240051) //update audio fail) usually push audio more than wanted. +#define MIC_ERROR (240052) //get audio data fail continually for 2 senconds. + +//nls error +#define CREATE_DA_REQUEST_ERROR (240060) //create dialog assistant fail. +#define START_DA_REQUEST_ERROR (240061) //start dialog assistant fail. +#define DEFAULT_NLS_ERROR (240062) //default network error. +#define SSL_ERROR (240063) //ssl new fail. +#define SSL_CONNECT_FAILED (240064) //ssl connect fail. +#define HTTP_CONNECT_FAILED (240065) //http connect fail. +#define DNS_FAILED (240066) //DNS fail. +#define CONNECT_FAILED (240067) //connect socket fail. +#define SERVER_NOT_ACCESS (240068) //server responce status bad. +#define SOCKET_CLOSED (240069) //socket closed. +#define AUTH_FAILED (240070) //authentication fail. +#define HTTPDNS_FAILED (240071) //connect fail when use http dns. +#define HTTP_SEND_FAILED (240072) +#define HTTP_RECEIVE_FAILED (240073) +#define HTTP_RESPONSE_ERROR (240074) +#define HTTP_SERVER_ERROR (240075) + +//function call timeout +#define ENGINE_INIT_TIMEOUT (240080) //init engine timeout. +#define SET_PARAM_TIMEOUT (240081) //set parameters timeout. +#define SET_WUW_TIMEOUT (240082) //set wakeup word timeout. +#define SELECT_RECORDER_TIMEOUT (240083) //select recorder timeout. +#define STOP_TIMEOUT (240084) //stop dialog timeout. +#define ASR_ENGINE_STOP_TIMEOUT (240085) //asr engine stop timeout. +#define UNLOAD_DYNAMIC_WUW_TIMEOUT (240086) //unload dynamic wakeup word timeout. +#define ADD_DYNAMIC_WUW_TIMEOUT (240087) //add dynamic wakeup word timeout. +#define HANDLE_API_TIMEOUT (240088) +#define CHECK_ASSET_TIMEOUT (240089) //check asset bin file timeout +//network timeout +#define UPDATE_CONTEXT_TIMEOUT (240090) //update context timeout. +#define CONNECTION_TIMEOUT (240091) //connect to server timeout. +#define PARTIAL_ASR_TIMEOUT (240092) //get partial asr result timeout. +#define ASR_TIMEOUT (240093) //get final asr result timeout. +#define DIALOG_TIMEOUT (240094) //get dialog result timeout. +#define WWV_TIMEOUT (240095) //get wake word verification result timeout. +//legacy +#define WAIT_TIMEOUT (240100) //legacy definition. + +#ifdef NUI_VIDEO_MANAGER +#define SELECT_CAMERA_TIMEOUT (240101) +#define SELECT_CAMERA_ERROR (240102) +#endif +#ifdef NUI_SENSOR_MANAGER +#define SELECT_SENSOR_TIMEOUT (240103) +#define SELECT_SENSOR_ERROR (240104) +#endif +#ifdef NUI_INCLUDE_VPR +#define VPR_ARGS_INVALID (240110) +#define VPR_REG_TIMEOUT (240111) +#define VPR_STATE_INVALID (240112) +#define VPR_UPDATE_TIMEOUT (240113) +#define VPR_DELETE_TIMEOUT (240114) +#define VPR_CANCEL_TIMEOUT (240115) +#endif + +#define RING_BUF_WRITE_FAIL (240120) +#define AUDIO_PROCESS_THREAD_BLOCK (240121) +#define PERFORMANCE_FILEOVER (240130) + +#define RESAMPLE_ERR (240140) +#define FILE_TRANS_TASK_LIMIT (240150) +#define FILE_TRANS_ENCODER_FAIL (140151) + +#define THREAD_CREATE_FAILD (240200) +#define SERVER_ERR_SILENT_SPEECH (40010007) + +#endif /* NeoNuiCode_h */ diff --git a/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiTts.h b/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiTts.h new file mode 100644 index 0000000..31f239c --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiTts.h @@ -0,0 +1,84 @@ +// +// NeoNuiTts.h +// nuisdk +// +// Created by zhouguangdong on 2019/12/21. +// Copyright © 2019年 zhouguangdong. All rights reserved. +// + +#ifndef NeoNuiTts_h +#define NeoNuiTts_h + +#ifdef DEBUG_MODE +#define TLog( s, ... ) NSLog( s, ##__VA_ARGS__ ) +#else +#define TLog( s, ... ) +#endif + +#import +#import "NeoNuiCode.h" + + +enum NuiSdkTtsEvent { + TTS_EVENT_START = 0, + TTS_EVENT_END = 1, + TTS_EVENT_CANCEL = 2, + TTS_EVENT_PAUSE = 3, + TTS_EVENT_RESUME = 4, + TTS_EVENT_ERROR = 5, + TTS_EVENT_CACEH_START = 6, + TTS_EVENT_CACEH_END = 7, + TTS_EVENT_CACEH_CANCEL = 8, + TTS_EVENT_CACEH_DELETE = 9, + TTS_EVENT_CACEH_ERROR = 10, + TTS_EVENT_FONT_EVENT_START = 11, + TTS_EVENT_FONT_DOWNLOAD = 12, + TTS_EVENT_FONT_END = 13, + TTS_EVENT_FONT_PAUSE = 14, + TTS_EVENT_FONT_RESUME = 15, + TTS_EVENT_FONT_CANCEL = 16, + TTS_EVENT_FONT_ERROR = 17 +}; + +typedef enum NuiSdkTtsEvent NuiSdkTtsEvent; + +@protocol NeoNuiTtsDelegate +@optional +- (void)onNuiTtsEventCallback:(NuiSdkTtsEvent)event taskId:(char*)taskid code:(int)code; +- (void)onNuiTtsUserdataCallback:(char*)info infoLen:(int)info_len buffer:(char*)buffer len:(int)len taskId:(char*)task_id; +- (void)onNuiTtsVolumeCallback:(int)volume taskId:(char*)task_id; +- (void)onNuiTtsLogTrackCallback:(NuiSdkLogLevel)level + logMessage:(const char *)log; +@end + +@interface NeoNuiTts : NSObject +@property (readonly) void* nui_tts_sdk; +@property (nonatomic,weak) id delegate; + ++ (instancetype)get_instance; + +-(int) nui_tts_initialize:(const char *)parameters + logLevel:(NuiSdkLogLevel)level + saveLog:(BOOL)save_log; + +-(int) nui_tts_release; + +-(int) nui_tts_play:(const char *)priority + taskId:(const char *)taskid + text:(const char *)text; + +-(int) nui_tts_cancel:(const char *)taskid; + +-(int) nui_tts_pause; + +-(int) nui_tts_resume; + +-(int) nui_tts_set_param:(const char *)param + value:(const char *)value; + +-(const char *) nui_tts_get_param:(const char *)param; + +-(int) nui_tts_get_num_of_chars:(const char *)param; +@end + +#endif /* NeoNuiTts_h */ diff --git a/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiUtils.h b/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiUtils.h new file mode 100644 index 0000000..692ae6d --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Headers/NeoNuiUtils.h @@ -0,0 +1,43 @@ +#ifndef NeoNuiUtils_h +#define NeoNuiUtils_h + +#ifdef DEBUG_MODE +#define TLog( s, ... ) NSLog( s, ##__VA_ARGS__ ) +#else +#define TLog( s, ... ) +#endif + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface NeoNuiUtils : NSObject + +@property (readonly) void* nui_utils; + ++ (instancetype)get_instance; + +- (const char *)nui_utils_refresh_apikey:(const char *)apikey + Url:(const char *)url; + +- (const char *)nui_uitls_get_version; + +- (const char *)nui_uitls_get_app_code; + ++ (NSString *)replenishHardwareInfo:(NSString *)params; + ++ (NSDictionary *)hardwareInfo; + ++ (NSString*)getDeviceType; + ++ (NSString *)getDeviceModel; + ++ (NSString*)getDeviceName; + +@end + +NS_ASSUME_NONNULL_END + +#endif /* NeoNuiUtils_h */ + diff --git a/Vendor/AliyunNui/nuisdk.framework/Headers/StreamInputTts.h b/Vendor/AliyunNui/nuisdk.framework/Headers/StreamInputTts.h new file mode 100644 index 0000000..4107014 --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Headers/StreamInputTts.h @@ -0,0 +1,131 @@ +// +// NeoNuiStreamInputTts.h +// nuisdk +// +// Created by lengjiayi on 2024/4/17. +// Copyright © 2024 zhouguangdong. All rights reserved. +// + +#ifndef StreamInputTts_h +#define StreamInputTts_h + +#import +#import "NeoNuiCode.h" + +enum StreamInputTtsCallbackEvent { + TTS_EVENT_SYNTHESIS_STARTED = 0, + TTS_EVENT_SENTENCE_BEGIN = 1, + TTS_EVENT_SENTENCE_SYNTHESIS = 2, + TTS_EVENT_SENTENCE_END = 3, + TTS_EVENT_SYNTHESIS_COMPLETE = 4, + TTS_EVENT_TASK_FAILED = 5 +}; + +typedef enum StreamInputTtsCallbackEvent StreamInputTtsCallbackEvent; + +@protocol StreamInputTtsDelegate +@optional +/** + * 事件回调 + * @param event 回调事件,参见如下事件列表。 + * @param taskid 请求的任务ID,每次调用一个新ID。 + * @param sessionId 请求的会话ID。 + * @param ret_code 参见错误码,出现TTS_EVENT_ERROR事件时有效,可查阅xxxxx。 + * @param error_msg 当产生错误码时,返回错误信息。 + * @param timestamp 时间戳信息。 + * @param all_response 返回的完整json格式信息。 + */ +- (void)onStreamInputTtsEventCallback:(StreamInputTtsCallbackEvent)event taskId:(char*)taskid + sessionId:(char*)sessionId ret_code:(int)ret_code + error_msg:(char*)error_msg timestamp:(char*)timestamp all_response:(char*)all_response; +/** + * 当合成数据时,此回调被连续调用,App需要在回调中获得合成的音频数据 + * @param buffer 合成的语音数据 + * @param len 合成的语音长度 + */ +- (void)onStreamInputTtsDataCallback:(char*)buffer len:(int)len; + +- (void)onStreamInputTtsLogTrackCallback:(NuiSdkLogLevel)level + logMessage:(const char *)log; +@end + + +@interface StreamInputTts : NSObject +@property (nonatomic,weak) id delegate; + ++ (instancetype)get_instance; + +/** + * 与服务端完成建链,并开始流式语音合成任务 + * @param ticket json string形式的鉴权参数,参见下方说明或接口说明:xxxxx + * @param parameters json string形式的初始化配置参数,参见下方说明或接口说明:xxxxx + * @param sessionId 会话ID,可传入32个字节的uuid,或传入空内容由SDK自动生成。 + * @param logLevel log打印级别,值越小打印越多。 + * @param saveLog 是否保存log为文件,存储目录为ticket中的debug_path字段值。 + * @return:参见错误码:https://help.aliyun.com/document_detail/459864.html。 + */ +- (int) startStreamInputTts:(const char *)ticket parameters:(const char *)parameters + sessionId:(const char *)sessionId logLevel:(NuiSdkLogLevel)logLevel + saveLog:(BOOL)saveLog; + +/** + * 与服务端完成建链,并开始单向流式语音合成(长文本语音合成)任务,同步接口 + * @param ticket json string形式的鉴权参数,参见下方说明或接口说明:xxxxx + * @param parameters json string形式的初始化配置参数,参见下方说明或接口说明:xxxxx + * @param text 待合成文本 + * @param sessionId 会话ID,可传入32个字节的uuid,或传入空内容由SDK自动生成。 + * @param logLevel log打印级别,值越小打印越多。 + * @param saveLog 是否保存log为文件,存储目录为ticket中的debug_path字段值。 + * @return:参见错误码:https://help.aliyun.com/document_detail/459864.html。 + */ +- (int) playStreamInputTts:(const char *)ticket parameters:(const char *)parameters + text:(const char*)text sessionId:(const char *)sessionId + logLevel:(NuiSdkLogLevel)logLevel saveLog:(BOOL)saveLog; + +/** + * 与服务端完成建链,并开始单向流式语音合成(长文本语音合成)任务,异步接口 + * @param ticket json string形式的鉴权参数,参见下方说明或接口说明:xxxxx + * @param parameters json string形式的初始化配置参数,参见下方说明或接口说明:xxxxx + * @param text 待合成文本 + * @param sessionId 会话ID,可传入32个字节的uuid,或传入空内容由SDK自动生成。 + * @param logLevel log打印级别,值越小打印越多。 + * @param saveLog 是否保存log为文件,存储目录为ticket中的debug_path字段值。 + * @return:参见错误码:https://help.aliyun.com/document_detail/459864.html。 + */ +- (int) asyncPlayStreamInputTts:(const char *)ticket parameters:(const char *)parameters + text:(const char*)text sessionId:(const char *)sessionId + logLevel:(NuiSdkLogLevel)logLevel saveLog:(BOOL)saveLog; + +/** + * 以流式的方式发送文本 + * @param text 从大模型当中生成的流式文本 + * @return:参见错误码:https://help.aliyun.com/document_detail/459864.html。 + */ +- (int) sendStreamInputTts:(const char *)text; + +/** + * 结束合成任务,通知服务端流入文本数据发送完毕,阻塞等待服务端处理完成,并返回所有合成音频。阻塞超时可以通过start接口中的complete_waiting_ms设置 + * @return:参见错误码:https://help.aliyun.com/document_detail/459864.html。 + */ +- (int) stopStreamInputTts; + +/** + * 结束合成任务,通知服务端流入文本数据发送完毕,不等待而是立即返回,同时回调继续返回剩余内容 + * @return:参见错误码:https://help.aliyun.com/document_detail/459864.html。 + */ +- (int) asyncStopStreamInputTts; + +/** + * 立即停止合成任务,不会有任何回调返回 + * @return:参见错误码:https://help.aliyun.com/document_detail/459864.html。 + */ +- (int) cancelStreamInputTts; + +/** + * 手动释放合成任务 + */ +- (int) releaseStreamInputTts; + +@end + +#endif /* NeoNuiStreamInputTts_h */ diff --git a/Vendor/AliyunNui/nuisdk.framework/Info.plist b/Vendor/AliyunNui/nuisdk.framework/Info.plist new file mode 100644 index 0000000..4472640 Binary files /dev/null and b/Vendor/AliyunNui/nuisdk.framework/Info.plist differ diff --git a/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/cei.json b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/cei.json new file mode 100644 index 0000000..733af4c --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/cei.json @@ -0,0 +1,88 @@ +{ + "cei":{ + "cei_param_version":"2.2.0", + "cei_param_device_type":"car", + "cei_param_reco_mode":0, + "cei_param_log_level":2, + "cei_param_debug_path":"asr_debug", + "cei_param_is_debug_enable":false, + "cei_param_resource_path":"", + "cei_param_resource_is_multi_language":false, + "cei_param_audio_format_str":"16k16bitmono", + "cei_param_mcs_mode":0, + "cei_param_work_mode":0, + "cei_param_max_cache_frames":1000, + "cei_param_is_aec_bf_active":false, + "cei_param_is_agc_active":false, + "cei_param_is_vad_active":true, + "cei_param_is_kws_active":true, + "cei_param_is_sr_active":true + }, + "asp":{ + "asp_param_is_process_parallel":false, + "asp_param_is_input_debug_enable":false, + "asp_param_is_output_debug_enable":false, + "asp_param_debug_path":"asr_debug", + "asp_param_is_callback_enable":false, + "asp_param_callback_period_frames":5 + }, + "vad":{ + "vad_param_is_input_debug_enable":false, + "vad_param_is_output_debug_enable":false, + "vad_param_debug_path":"asr_debug", + "vad_param_asleep_speech_noise_thres":-0.8, + "vad_param_awake_speech_noise_thres":-0.5, + "vad_param_asleep_max_speech_segment_time":300000, + "vad_param_awake_max_speech_segment_time":60000, + "vad_param_asleep_block_size":3, + "vad_param_awake_block_size":3, + "vad_param_front_timeout_interval":8000, + "vad_param_tail_timeout_interval":800, + "vad_param_is_detect_start":true, + "vad_param_is_detect_end":true + }, + "kws":{ + "kws_param_is_input_debug_enable":false, + "kws_param_is_output_debug_enable":false, + "kws_param_debug_path":"asr_debug", + "kws_param_is_process_parallel":false, + "kws_param_front_extend_frames":10, + "kws_param_tail_extend_frames":5, + "kws_param_encoder_type_str":"opu", + "kws_param_encoder_bitrate":16000, + "kws_param_encoder_complexity":2, + "kws_param_callback_period_ms":100, + "kws_param_max_frames_per_callback":25, + "kws_param_max_bytes_per_callback":16000 + }, + "sr":{ + "sr_param_is_input_debug_enable":false, + "sr_param_is_output_debug_enable":false, + "sr_param_debug_path":"asr_debug", + "sr_param_is_itn_enable":true, + "sr_param_is_do_conf_filter":false, + "sr_param_is_process_parallel":true, + "sr_param_is_need_result":false, + "sr_param_is_need_voice":true, + "sr_param_ngram_conf_thres":65.0, + "sr_param_jsgf_conf_thres":65.0, + "sr_param_encoder_type_str":"opu", + "sr_param_encoder_bitrate":16000, + "sr_param_encoder_complexity":2, + "sr_param_callback_period_ms":100, + "sr_param_max_frames_per_callback":25, + "sr_param_max_bytes_per_callback":16000 + }, + "ou":{ + "oss_upload_param_is_enable":false, + "oss_upload_param_asp_in":false, + "oss_upload_param_asp_out":false, + "oss_upload_param_vad_in":false, + "oss_upload_param_vad_out":false, + "oss_upload_param_kws_in":false, + "oss_upload_param_kws_susp_in":false, + "oss_upload_param_kws_out":false, + "oss_upload_param_kws_susp_out":false, + "oss_upload_param_sr_in":false + } +} diff --git a/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/copylist.txt b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/copylist.txt new file mode 100644 index 0000000..d1cd78a --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/copylist.txt @@ -0,0 +1,3 @@ +tts +nui.json +cei.json diff --git a/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/nui.json b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/nui.json new file mode 100644 index 0000000..63f7992 --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/nui.json @@ -0,0 +1,54 @@ +{ + "device_type":"phone", + "assets_version":"1.1.20241216", + "nui_config":{ + "service_mode":"kModeFullCloud", + "log_level":"kLogLevelVerbose", + "enable_recorder_by_user":true, + "enable_dialog":false + }, + "nls_config":{ + "debug_level":4, + "sr_format":"opus", + "sample_rate":16000, + "dns_timeout":5000, + "vocab_default_weight":2 + }, + "audio_config":{ + "debug":"none", + "16k_audio":{ + "name":"16kmono16bit", + "id":0, + "mic":{ + "name":"16kmono16bit", + "debug_heap_pollution":false, + "read_cnt":0, + "sample_rate":16000, + "bits_per_sample":16, + "channels":1, + "recording_interval":10, + "cei_frame_time_len":20, + "channel_mask":"kAMChannalMaskNone", + "format_type":"kAMDataFormatPcmInterleaved", + "endianness":"kAMByteOrderLittleEndian" + } + }, + "8k_audio":{ + "name":"8kmono16bit", + "id":1, + "mic":{ + "name":"8kmono16bit", + "debug_heap_pollution":false, + "read_cnt":0, + "sample_rate":8000, + "bits_per_sample":16, + "channels":1, + "recording_interval":10, + "cei_frame_time_len":20, + "channel_mask":"kAMChannalMaskNone", + "format_type":"kAMDataFormatPcmInterleaved", + "endianness":"kAMByteOrderLittleEndian" + } + } + } +} diff --git a/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/languagedata_embedded.bin b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/languagedata_embedded.bin new file mode 100644 index 0000000..3cbcebb Binary files /dev/null and b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/languagedata_embedded.bin differ diff --git a/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/parameter.cfg b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/parameter.cfg new file mode 100644 index 0000000..15d9779 --- /dev/null +++ b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/parameter.cfg @@ -0,0 +1,31 @@ +{ + "device_type":"mobile", + "mode_type":2, + "smart_engine":3, + "encode_type":"pcm", + "debug_info":{ + "debug_level":0, + "debug_path":"/sdcard/tmp" + }, + "common_info":{ + "pitch_level":0, + "speed_level":1.0, + "volume":1.0, + "play_audio":0, + "callback_raw_data":0, + "sample_rate":16000, + "font_name":"xiaoyun", + "wait_time":2000 + }, + "cache":{ + "on":false, + "max_cache_size":2000000, + "save_path":"/sdcard/mit/" + }, + "font":{ + "on":false, + "mode":"cmd", + "save_path":"/sdcard/mit" + } +} + diff --git a/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/voices/aijia b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/voices/aijia new file mode 100644 index 0000000..2348d11 Binary files /dev/null and b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/voices/aijia differ diff --git a/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/voices/voicefont.bin b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/voices/voicefont.bin new file mode 100644 index 0000000..8c201bb Binary files /dev/null and b/Vendor/AliyunNui/nuisdk.framework/Resources.bundle/tts/voices/voicefont.bin differ diff --git a/Vendor/AliyunNui/nuisdk.framework/nuisdk b/Vendor/AliyunNui/nuisdk.framework/nuisdk new file mode 100755 index 0000000..08c1770 Binary files /dev/null and b/Vendor/AliyunNui/nuisdk.framework/nuisdk differ diff --git a/Vendor/AliyunNui/version_history_028_iOS.txt b/Vendor/AliyunNui/version_history_028_iOS.txt new file mode 100644 index 0000000..8ce050b --- /dev/null +++ b/Vendor/AliyunNui/version_history_028_iOS.txt @@ -0,0 +1,358 @@ +V2.6.4-028-20250410_iOS +NUI: +分支: release/2.6.x/baseline +commit id: 7427b38b4fd9723b267c7e5ba767405e7fbe646e +说明: + 1. 减少识别时调用stop接口返回最终识别结果的延迟 + 2. 纯云端功能支持不传入workspace + 3. 一句话识别增加链接超时相关参数设置;一句话识别新增EVENT_ASR_STARTED事件, 用于返回RecognitionStarted事件及task_id + 4. 日志系统增加行号显示 + 5. 修复一句话识别返回2个EVENT_VAD_END的问题;修复一句话识别不返回错误信息和完整response的问题; + 6. 修复录音文件极速版初始化失败时调用start崩溃的问题 + 7. 修复唤醒029在初始化时鉴权2次的问题; 纯离线功能时跳过token刷新功能 + 8. 允许非离线语音合成的SDK_CODE使用离线语音合成功能。但是不允许标准版离线语音合成使用精品版离线语音合成的发音人包 + 9. 录音文件识别极速版增加音频文件链接识别的功能 + 10.实时识别新增EVENT_TRANSCRIBER_STARTED事件, 用于返回TranscriptionStarted事件及task_id + 11.修复离线TTS的SpeedRate&PitchRate&Volume设置无效的问题;修复离线TTS设置pitch_level后合成音频异常的问题;修复离线TTS设置错误extend_font_name会崩溃的问题 + 12.新增开关参数stash_partial_result。在实时识别和听悟实时推流时说长句子时,SDK内部会将返回SentenceEnd的json repsone中的stash_result(暂存的中间结果)覆盖payload.result,并以EVENT_ASR_PARTIAL_RESULT事件返回。新增参数将可开关这个功 >能 + 13.修复语音合成某些情况下get_param("error_msg")获得空结果的问题。 + 14.语音合成增加dns_timeout设置参数 + 15.日志系统增加设置控制日志文件大小的功能 + 16.流式语音合成增加cancel()和async_stop()接口 + 17.修复SDK使用新版本鉴权后退回到旧SDK使用旧版本鉴权启动会有json::LogicError-asCString崩溃的问题 + 18.增加日志信息回送的方式, 可通过此回调将SDK运行日志送到用户层 + 19.修复SSL获得数据时偶现TRY_AGAIN上报错误的问题 + 20.修复iOS平台使用时, 当获得的sockFd大于1024(FD_SETSIZE)时, connect()建连会失败, 上报EINPROGRESS错误和Invalid argument错误信息。 + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: 55d88b7c81f28fb10720983b3840f6ee673d1798 +说明: + 1. 修复离线TTS的SpeedRate&PitchRate&Volume设置范围, 增加PitchRate设置接口 + +NuiDemoForUnit: +分支: develop/V2.6.x/baseline +commit id: 4d914b2c489d79ff3a577f46ad8c59feb40b84e0 +说明: + 1. Android/iOS 增加运行过程中刷新token的示例 + 2. Android/iOS 纯云端功能可不传入workspace + 3. Android/iOS 一句话识别和实时识别增加启动事件EVENT_ASR_STARTED和EVENT_TRANSCRIBER_STARTED, 主要用于获得TaskId + 4. Android 去掉不必要的权限android.permission.WRITE_EXTERNAL_STORAGE和android.permission.READ_EXTERNAL_STORAGE + 5. Android 离线语音合成示例增加发音人语音包选择下载的功能,增加SpeedRate和PitchRate和Volume设置,方便演示 + 6. Android 语音合成示例增加SpeedRate和PitchRate和Volume设置,增加音频保存的示例,增加dns_timeout参数设置 + 7. Android 录音文件转写增加参数设置, 包括添加一些演示音频 + 8. Android 听悟实时推流demo新增关闭stash_partial_result的示例 + 9. Android/iOS 流式语音合成增加async_stop和cancel接口 + 10.Android 用于存储调试信息的debug_path调整到debug目录下。同时增加本地存储日志文件的最大字节数的设置。 + 11.Android 新增在线和离线语音合成混合使用的示例工程 + 12.iOS 去掉一些涉及权限信息的不必要操作 + 13.iOS 识别的调用接口改为同步,解决多实例运行可能崩溃的问题 + + +V2.6.3-028-20241220_iOS +NUI: +分支: develop/2.6.3/baseline +commit id: 65097ea96579064487f2f51c9fefb7151858909d +说明: + 1. 修复一句话识别返回2个EVENT_VAD_END的问题 + 2. 修复一句话识别不返回错误信息和完整response的问题 + 3. 修复录音文件极速版初始化失败时调用start崩溃的问题 + 4. 修复唤醒029在初始化时鉴权2次的问题; 纯离线功能时跳过token刷新功能 + 5. 允许非离线语音合成的SDK_CODE使用离线语音合成功能。但是不允许标准版离线语音合成使用精品版离线语音合成的发音人>包 + 6. 录音文件识别极速版增加音频文件链接识别的功能 + 7. 修复不传入workspace无法使用8K语音识别的问题 + 8. 实时识别新增EVENT_TRANSCRIBER_STARTED事件, 用于返回TranscriptionStarted事件及task_id + 9. 一句话识别新增EVENT_ASR_STARTED事件, 用于返回RecognitionStarted事件及task_id + 10.修复离线TTS的SpeedRate&PitchRate&Volume设置无效的问题 + 11.修复离线TTS设置错误extend_font_name会崩溃的问题 + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: d2cb1d2245b2497fffb87f09b700b0468af61969 +说明: + 1. 修复离线TTS的SpeedRate&PitchRate&Volume设置范围, 增加PitchRate设置接口 + +NuiDemoForUnit: +分支: develop/V2.6.x/baseline +commit id: 193e89bc3d143b2ccef11cf262dd6a1154ad9e2c +说明: + 1. iOS 增加运行过程中刷新token的示例 + 2. iOS 增加识别结果的展示 + 3. iOS device_id和workspace可不设置的说明 + 4. iOS 去掉一些涉及权限信息的操作 + 5. Android 移除android.permission.WRITE_EXTERNAL_STORAGE和android.permission.READ_EXTERNAL_STORAGE + 6. Android 离线TTS demo增加发音人文件下载, 方便演示 + 7. Android TTS中增加语速语调音量发音人和音频编码的设置, 增加音频保存 + 8. Android 一句话识别和实时识别增加启动事件EVENT_ASR_STARTED和EVENT_TRANSCRIBER_STARTED, 主要用于获得TaskId + 9. Android 录音文件转写增加参数设置, 包括添加一些演示音频 + + +V2.6.0-028-20240708_iOS +NUI: +分支: develop/2.6.0/baseline +commit id: 3fd4f2b70939cb5045304511ac85f2b4f894615c +说明: + 1. + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: acca4c2bad4a683aca444639fc16c55c01134dea +说明: + 1. + +NuiDemoForUnit: +分支: develop/V2.6.x/baseline +commit id: 5dcb206d05dd1051b4bcaa67dccfba42ef5fd598 +说明: + 1. 解决LOG_LEVEL_XXX命名冲突的问题 + 2. 解决离线TTS初始化传入空字符串崩溃的问题 + + +V2.6.0-028-20240627_iOS +NUI: +分支: develop/2.6.0/baseline +commit id: 3fd4f2b70939cb5045304511ac85f2b4f894615c +说明: + 1. OpenSSL增加加密套件协议,支持绝大多数服务端握手 + 2. SDK打包中增加头文件,可配合动态库进行cpp层面的调用开发 + 3. 流式TTS中参数设置不再仅支持字符串格式,支持格式可见官网文档 + 4. 增加流式输入TTS + 5. 增加单独唤醒的功能(仅029版本支持, 028/01B不支持) + 6. Android增加带符号表的备份动态库libneonui_with_symbols,用于崩溃时定位问题 + 7. 增加听悟实时记录功能 + 8. 回调返回完整信息 + 9. 改为OpenSSL作为底层网络库(仅01B支持) + 10.解决AuthV3查看linux_version时fgets栈溢出的问题。 + 11.修复长文本语音合成文本超16K字节无法合成的问题。 + 12.修复android_api < 24 找不到fseeko64的问题。 + 13.在kws.bin vad.bin加载处增加try-catch包裹, 防止崩溃。 + 14.RelWithDebugInfo 有符号表, 可用这个成果物进行调试 + 15.在线TTS增加用户自定义参数设置接口的示例, 可以设置文档中不存在的参数 + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: acca4c2bad4a683aca444639fc16c55c01134dea +说明: + 1. 唤醒相关更新 + 2. 修复本地vad模块内存泄露问题(仅029版本支持, 028/01B不支持) + 3. 本地唤醒模块升级(仅029版本支持, 028/01B不支持) + 4. 在kws.bin vad.bin加载处增加try-catch包裹, 防止崩溃。 + 5. 增加对资源文件tts/languagedata_embedded.bin完整性校验,仍然支持老的languagedata_embedded.bin + 6. 打印信息中打印md5,用于当kws资源文件异常时排查 + +NuiDemoForUnit: +分支: develop/V2.6.x/baseline +commit id: e3dfbec03d8ab510ba97fe39e83dd55258080d8d +说明: + 1. 更新iOS示例工程中录音播放相关代码,原旧版本录音播放相关代码继续保留 + 2. iOS/Android中鉴权相关代码重构,支持SecurityToken的鉴权示例 + 3. Android中增加万能参数设置的示例 + 4. 增加流式输入TTS范例工程 + 5. 更新录音文件转写极速版范例工程 + 6. 增加单唤醒功能范例工程(仅029版本支持, 028/01B不支持) + 7. 增加听悟实时记录范例工程 + 8. Android targetSdkVersion提升到33 + + +V2.5.14-028-20230807_iOS +NUI: +分支: develop/2.5.15/baseline +commit id: 7be5435c4f2d881b406796fce824e9f891cde17d +说明: + 1. 修复zlib 1.2.13引入的,小于ANDROID_API<24时找不到fseeko64的问题 + 2. 修复超过16KB字节超长文本无法工作的问题,引入多包功能,若无法工作请切换深圳的url + 3. 删除无用的代码 + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: f5f0039f2f3a09d69445dd7530bb7c42511250da +说明: + 1. + +NuiDemoForUnit: +分支: develop/V2.5.15/baseline +commit id: 3f656fdc96c5bcfc369a1346863134c3a195079f +说明: + 1. iOS工程中解决播放后快速暂停恢复无法播放音频的问题 + +resources: + 见当前目录中resources + 1. 离线TTS资源文件 tts/languagedata_embedded.bin 在原文件(md5:e8167925c9a9bc03272746950ba6ab5e)基础上增加md5头,用于校验数据完整性。 + 2. 离线TTS资源文件 tts/languagedata_embedded.bin 添加补全SSML规则 + +Note: + + +V2.5.14-028-20230725_iOS +NUI: +分支: develop/2.5.14-01B/unit_merge_taobaozhibo +commit id: 2273440efac799361b29dfee5ee3706e54ceeff6 +说明: + 1. iOS 最低支持iOS9 + 2. iOS Clang 提供了一种控制退出时析构函数注册的机制, 增加-fno-c++-static-destructors编译选项,解决偶现释放崩溃的问题。 + 3. 解决网络传输结束时偶现的多次释放线程导致的崩溃问题 + 4. 替换新的鉴权方案,兼容旧鉴权方案,隐私政策请详见《隐私政策.txt》 + 5. 升级zlib 1.2.11到1.2.13,修复zlib的漏洞编号MPS-2022-53300 + 6. 修复不设置debug_path,且workspace只读的情况下报错误码240022的问题。 + 7. 修复sts_token设置无效的问题 + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: f5f0039f2f3a09d69445dd7530bb7c42511250da +说明: + 1. 增加对资源文件tts/languagedata_embedded.bin完整性校验,仍然支持老的languagedata_embedded.bin + 2. 增加代码保护,以解决离线TTS偶现的Tacotron::Tacotron(PhoneSet*) 崩溃问题。 + 3. iOS Clang 提供了一种控制退出时析构函数注册的机制, 增加-fno-c++-static-destructors编译选项,解决偶现释放崩溃的问题。 + 4. iOS 支持最小版本为iOS9 + +NuiDemoForUnit: +分支: release/V2.5.14-01B/unit_merge_taobaozhi +commit id: edf69a4fdd19b1bdc37c3dc21318aac8327ec722 +说明: + 1. iOS nuisdk工程中CLANG_ENABLE_CPP_STATIC_DESTRUCTORS = NO 以解决偶现释放崩溃的问题 + 2. iOS demo工程中NLSRingBuffer.m中加入内存扩大机制,解决TTS播放取数据太慢导致SDK内部数据塞爆std::vector崩溃的问题。 + 3. iOS demo工程在线TTS范例添加多段短文本语音合成的范例。 + 4. iOS demo工程离线TTS范例解决播放无声音的问题。 + 3. iOS&Android nuisdk工程增加获取手机硬件信息的功能,用于离线TTS新鉴权方案。替换新的鉴权方案,只获得符合安全和隐私规范的硬件信息。 + 4. iOS&Android demo工程更新阿里云账号范例,强烈推荐使用sts安全方式获得账号。 + 5. iOS&Android demo工程获得用户权限进行收敛,仅需要录音/播放权限、磁盘存储权限。 + 6. iOS&Android demo工程修复一句话识别示例中VAD/P2T模式设置的说明。 + 7. iOS&Android demo工程修复极速文件转写功能启动会进行鉴权的问题。 + +resources: + 见当前目录中resources + 1. 离线TTS资源文件 tts/languagedata_embedded.bin 在原文件(md5:061931cea06b913cfb4a7d1c0fbd686cMindJB)基础上增加md5头,用于校验数据完整性 + +Note: + 新鉴权方案(authV3)仅采集用户手机型号、主板型号等符合安全规范的信息。 + + +V2.5.14-028-20230415_iOS +NUI: +分支: develop/2.5.14-01B/unit_merge_taobaozhibo +commit id: 243debb6bf25e44c775587435ebb258c79023c59 +说明: + 1. iOS: Clang 提供了一种控制退出时析构函数注册的机制, 可以使用>标志 -fno-c++-static-destructors全局禁用它们. + 2. MBEDTLS_SSL_VERIFY_REQUIRED 改成 MBEDTLS_SSL_VERIFY_NONE. 解决服务器SSL证书中间证书颁发机构变化导致mbedtls握手失败. 这个是紧>急修复,要求用户必须更新此版本以后SDK. + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: 446281f4517651d2dbfa84cc05e14f401ebda6b3 +说明: + 1. 修复英文唤醒词加载问题 + +NuiDemoForUnit: +分支: release/V2.5.14-01B/unit_merge_taobaozhi +commit id: 6e7386ba7b8d8facd5693746a3804ef95349c56b +说明: + 1. xcode修改 CLANG_ENABLE_CPP_STATIC_DESTRUCTORS = NO + 2. iOS demo工程的NLSRingBuf增加动态扩大机制,防止超长文本语音合 +成数据阻塞的问题 + +resources: + 见当前目录中resources + +Note: + + +V2.5.14-028-20230331_iOS +NUI: +分支: develop/2.5.14-01B/unit_merge_taobaozhibo +commit id: fb5c6c45984d7875aff52c9268418be5c32c9262 +说明: + 1. MBEDTLS_SSL_VERIFY_REQUIRED 改成 MBEDTLS_SSL_VERIFY_NONE + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: 1ee0641a3abc60039bf57041fd90b95f7bff8c31 +说明: + 1. + +NuiDemoForUnit: +分支: release/V2.5.14-01B/unit_merge_taobaozhi +commit id: b4f3836c253b9d6525c8d1981e9a479c29d4ead9 +说明: + 1. iOS demo工程的NLSRingBuf增加动态扩大机制,防止超长文本语音合成数据阻塞的问题 + +resources: + 见当前目录中resources + +Note: + + +V2.5.14-028-20221228_iOS +NUI: +分支: develop/2.5.14-01B/unit_merge_taobaozhibo +commit id: 58e6d066772077f487f45012ca1c2ac73c9e40b0 +说明: + 1. 增加STS支持 + 2. 修复动态唤醒词被设置默认参数的问题 + 3. 修复动态唤醒词空导致崩溃的问题 + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: a736171ecd5f13f64d7501a2a3d5449b4b60da4b +说明: + 1. 修复动态唤醒词空导致崩溃的问题 + +NuiDemoForUnit: +分支: release/V2.5.14-01B/unit_merge_taobaozhi +commit id: 9ec25aa1d25f61d11e8616494bae9d85fb60398b +说明: + 1. demo中增加STS支持 + +resources: + 见当前目录中resources + +Note: + + +V2.5.14-028-20221208_iOS +NUI: +分支: develop/2.5.14-01B/unit_merge_taobaozhibo +commit id: cc7b123a423eff68b65a6f4997dd26f1ab3b73a8 +说明: + 1. 增加对长文本语音合成的支持 + 2. 为支持xcode14, 去除armv7&i386, 关闭BITCODE + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: 296534a988302322332e0fb82fb6374a9accc8ab +说明: + 1. 为支持xcode14, 去除armv7&i386, 关闭BITCODE + +NuiDemoForUnit: +分支: release/V2.5.14-01B/unit_merge_taobaozhi +commit id: 1db94805b92ea9ef3a8142950d89b173e839b8eb +说明: + 1. 增加对长文本语音合成的支持 + 2. 为支持xcode14, 关闭BITCODE, ios最低11.0 + +resources: + 见当前目录中resources + +Note: + + +V2.5.14-028-20221108_iOS +NUI: +分支: develop/2.5.14-01B/unit_merge_taobaozhibo +commit id: 28950edab8ec3a3d0579a1aab05b752bb95b05ef +说明: 1. 修复NUI中speech_rate和speed_level换算错误的问题 + +SE(submodule): +分支: CEI_V2.3.x_Fix +commit id: f0123edf3320532c5a1b1f0abd72e70f3eac5425 +说明: 1. 删除不用的LOGE + +NuiDemoForUnit: +分支: release/V2.5.14-01B/unit_merge_taobaozhi +commit id: c47d8dec151e5a01f7c4e03054b807c5ead18c9c +说明: + 1. + +resources: + 见当前目录中resources + +Note: + diff --git a/suixinkan.xcodeproj/project.pbxproj b/suixinkan.xcodeproj/project.pbxproj index 0cd8f39..f16dd86 100644 --- a/suixinkan.xcodeproj/project.pbxproj +++ b/suixinkan.xcodeproj/project.pbxproj @@ -62,6 +62,11 @@ path = suixinkanTests; sourceTree = ""; }; + 9A01B8E22FFB84950033C402 /* Vendor */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Vendor; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,6 +120,7 @@ children = ( 93FF17C72FFB84950033C304 /* suixinkan */, A1B2C3D42FFB84950033C305 /* suixinkanTests */, + 9A01B8E22FFB84950033C402 /* Vendor */, 93FF17C62FFB84950033C304 /* Products */, 365F718ADCDEA1FB75E2A740 /* Pods */, 27017AD20F47EB5E23B21608 /* Frameworks */, @@ -141,6 +147,7 @@ 93FF17C12FFB84950033C304 /* Sources */, 93FF17C22FFB84950033C304 /* Frameworks */, 93FF17C32FFB84950033C304 /* Resources */, + 9A01B8E12FFB84950033C401 /* Embed Aliyun NUI SDK */, D83AC0A37C1CDC54D21AB6C9 /* [CP] Copy Pods Resources */, ); buildRules = ( @@ -315,6 +322,27 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-suixinkan/Pods-suixinkan-resources.sh\"\n"; showEnvVarsInLog = 0; }; + 9A01B8E12FFB84950033C401 /* Embed Aliyun NUI SDK */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "$(SRCROOT)/Vendor/AliyunNui/nuisdk.framework", + ); + name = "Embed Aliyun NUI SDK"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(TARGET_BUILD_DIR)/$(FRAMEWORKS_FOLDER_PATH)/nuisdk.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "set -e\nif [ \"${PLATFORM_NAME}\" = \"iphonesimulator\" ]; then\n exit 0\nfi\nFRAMEWORK_SOURCE=\"${SRCROOT}/Vendor/AliyunNui/nuisdk.framework\"\nFRAMEWORK_DEST=\"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}\"\nmkdir -p \"${FRAMEWORK_DEST}\"\nrsync -a --delete \"${FRAMEWORK_SOURCE}\" \"${FRAMEWORK_DEST}/\"\nif [ -n \"${EXPANDED_CODE_SIGN_IDENTITY}\" ]; then\n /usr/bin/codesign --force --sign \"${EXPANDED_CODE_SIGN_IDENTITY}\" --preserve-metadata=identifier,entitlements,flags \"${FRAMEWORK_DEST}/nuisdk.framework\"\nfi\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -354,6 +382,10 @@ DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/Vendor/AliyunNui", + ); INFOPLIST_FILE = suixinkan/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -369,6 +401,17 @@ "$(inherited)", "-ObjC", ); + "OTHER_LDFLAGS[sdk=iphoneos*]" = ( + "$(inherited)", + "-ObjC", + "-framework", + nuisdk, + "-framework", + AdSupport, + "-framework", + AudioToolbox, + "-liconv", + ); PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkan; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -397,6 +440,10 @@ DEVELOPMENT_TEAM = 56GVN5RNVN; ENABLE_USER_SCRIPT_SANDBOXING = NO; GENERATE_INFOPLIST_FILE = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/Vendor/AliyunNui", + ); INFOPLIST_FILE = suixinkan/Info.plist; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; @@ -412,6 +459,17 @@ "$(inherited)", "-ObjC", ); + "OTHER_LDFLAGS[sdk=iphoneos*]" = ( + "$(inherited)", + "-ObjC", + "-framework", + nuisdk, + "-framework", + AdSupport, + "-framework", + AudioToolbox, + "-liconv", + ); PRODUCT_BUNDLE_IDENTIFIER = com.yuanzhixiang.suixinkan; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/suixinkan/Features/ScenicQueue/Services/AliyunNLSTokenProvider.swift b/suixinkan/Features/ScenicQueue/Services/AliyunNLSTokenProvider.swift new file mode 100644 index 0000000..cfeb495 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Services/AliyunNLSTokenProvider.swift @@ -0,0 +1,135 @@ +// +// AliyunNLSTokenProvider.swift +// suixinkan +// + +import CryptoKit +import Foundation + +/// 阿里云 NLS token 实体,表示语音服务访问凭证及过期时间。 +struct AliyunNLSToken: Equatable { + let id: String + let expireTime: Int64? +} + +/// 阿里云 NLS token 获取协议,便于 TTS 管理器测试替换。 +protocol AliyunNLSTokenProviding { + /// 使用临时 STS 凭证创建 NLS token。 + func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken +} + +/// 阿里云 NLS token Provider,按 OpenAPI POP 签名请求 CreateToken。 +final class AliyunNLSTokenProvider: AliyunNLSTokenProviding { + typealias DataLoader = (URLRequest) async throws -> Data + + private let endpoint: URL + private let dateProvider: () -> Date + private let nonceProvider: () -> String + private let dataLoader: DataLoader + + init( + endpoint: URL = URL(string: "https://nls-meta.cn-shanghai.aliyuncs.com/")!, + dateProvider: @escaping () -> Date = Date.init, + nonceProvider: @escaping () -> String = { UUID().uuidString }, + dataLoader: @escaping DataLoader = { request in + let (data, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) { + throw AliyunNLSTokenError.httpStatus(http.statusCode) + } + return data + } + ) { + self.endpoint = endpoint + self.dateProvider = dateProvider + self.nonceProvider = nonceProvider + self.dataLoader = dataLoader + } + + func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken { + var params = signedBaseParams(credentials: credentials) + params["Signature"] = Self.signature(params: params, accessKeySecret: credentials.accessKeySecret) + let query = Self.canonicalQuery(params) + guard let url = URL(string: endpoint.absoluteString + "?" + query) else { + throw AliyunNLSTokenError.invalidURL + } + let request = URLRequest(url: url) + let data = try await dataLoader(request) + let response = try JSONDecoder().decode(AliyunNLSCreateTokenResponse.self, from: data) + guard let id = response.token?.id?.trimmingCharacters(in: .whitespacesAndNewlines), !id.isEmpty else { + throw AliyunNLSTokenError.missingToken + } + return AliyunNLSToken(id: id, expireTime: response.token?.expireTime) + } + + /// 构造 CreateToken 的未签名公共参数。 + func signedBaseParams(credentials: AliyunOSSCredentials) -> [String: String] { + [ + "AccessKeyId": credentials.accessKeyId, + "Action": "CreateToken", + "Format": "JSON", + "RegionId": "cn-shanghai", + "SignatureMethod": "HMAC-SHA1", + "SignatureNonce": nonceProvider(), + "SignatureVersion": "1.0", + "Timestamp": Self.timestampFormatter.string(from: dateProvider()), + "Version": "2019-02-28", + "SecurityToken": credentials.securityToken, + ] + } + + /// 生成阿里云 POP HMAC-SHA1 签名。 + static func signature(params: [String: String], accessKeySecret: String) -> String { + let canonical = canonicalQuery(params) + let stringToSign = "GET&%2F&\(percentEncode(canonical))" + let key = SymmetricKey(data: Data("\(accessKeySecret)&".utf8)) + let mac = HMAC.authenticationCode(for: Data(stringToSign.utf8), using: key) + return Data(mac).base64EncodedString() + } + + /// 生成按参数名排序后的 OpenAPI 查询串。 + static func canonicalQuery(_ params: [String: String]) -> String { + params + .sorted { $0.key < $1.key } + .map { "\(percentEncode($0.key))=\(percentEncode($0.value))" } + .joined(separator: "&") + } + + /// 阿里云 POP 签名要求的 RFC3986 编码。 + static func percentEncode(_ value: String) -> String { + var allowed = CharacterSet.alphanumerics + allowed.insert(charactersIn: "-_.~") + return value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + } + + private static let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter + }() +} + +/// 阿里云 NLS token 获取错误。 +enum AliyunNLSTokenError: Error, Equatable { + case invalidURL + case httpStatus(Int) + case missingToken +} + +private struct AliyunNLSCreateTokenResponse: Decodable { + let token: TokenPayload? + + enum CodingKeys: String, CodingKey { + case token = "Token" + } + + struct TokenPayload: Decodable { + let id: String? + let expireTime: Int64? + + enum CodingKeys: String, CodingKey { + case id = "Id" + case expireTime = "ExpireTime" + } + } +} diff --git a/suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.h b/suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.h new file mode 100644 index 0000000..a74d54a --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.h @@ -0,0 +1,59 @@ +// +// AliyunNuiTTSBridge.h +// suixinkan +// + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// 阿里云 NUI TTS 事件代理,将 Objective-C SDK 回调转成稳定的 Swift 边界。 +@protocol AliyunNuiTTSBridgeDelegate; + +/// 阿里云 NUI TTS Objective-C 桥接器,隔离 SDK 头文件和模拟器不可用逻辑。 +@interface AliyunNuiTTSBridge : NSObject + +/// SDK 回调代理。 +@property (nonatomic, weak, nullable) id delegate; + +/// 当前平台是否可直接加载 NUI TTS SDK。 +@property (nonatomic, readonly) BOOL sdkAvailable; + +/// 初始化 NUI TTS 引擎。 +- (NSInteger)initializeWithTicket:(NSString *)ticket logLevel:(NSInteger)logLevel saveLog:(BOOL)saveLog; + +/// 设置合成参数。 +- (NSInteger)setParam:(NSString *)param value:(NSString *)value; + +/// 开始合成指定文本。 +- (NSInteger)playWithPriority:(NSString *)priority taskId:(NSString *)taskId text:(NSString *)text; + +/// 取消指定任务,taskId 为空时取消全部任务。 +- (NSInteger)cancelWithTaskId:(nullable NSString *)taskId; + +/// 释放 SDK 资源。 +- (NSInteger)releaseEngine; + +/// 获取 SDK 参数值。 +- (nullable NSString *)paramValueForKey:(NSString *)key; + +@end + +/// 阿里云 NUI TTS 事件代理。 +@protocol AliyunNuiTTSBridgeDelegate + +/// 收到 TTS 状态事件。 +- (void)aliyunNuiTTSBridge:(AliyunNuiTTSBridge *)bridge + didReceiveEvent:(NSInteger)event + taskId:(nullable NSString *)taskId + code:(NSInteger)code; + +/// 收到 TTS 音频数据。 +- (void)aliyunNuiTTSBridge:(AliyunNuiTTSBridge *)bridge + didReceiveAudioData:(NSData *)data + taskId:(nullable NSString *)taskId + info:(nullable NSString *)info; + +@end + +NS_ASSUME_NONNULL_END diff --git a/suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.m b/suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.m new file mode 100644 index 0000000..16334c7 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Services/AliyunNuiTTSBridge.m @@ -0,0 +1,138 @@ +// +// AliyunNuiTTSBridge.m +// suixinkan +// + +#import "AliyunNuiTTSBridge.h" +#import + +#if !TARGET_OS_SIMULATOR && __has_include() +#import +#define ALIYUN_NUI_TTS_AVAILABLE 1 +#else +#define ALIYUN_NUI_TTS_AVAILABLE 0 +#endif + +#if ALIYUN_NUI_TTS_AVAILABLE +@interface AliyunNuiTTSBridge () +@property (nonatomic, strong, nullable) NeoNuiTts *nui; +@end +#else +@interface AliyunNuiTTSBridge () +@end +#endif + +@implementation AliyunNuiTTSBridge + +- (BOOL)sdkAvailable { +#if ALIYUN_NUI_TTS_AVAILABLE + return YES; +#else + return NO; +#endif +} + +- (NSInteger)initializeWithTicket:(NSString *)ticket logLevel:(NSInteger)logLevel saveLog:(BOOL)saveLog { +#if ALIYUN_NUI_TTS_AVAILABLE + if (self.nui != nil) { + [self.nui nui_tts_release]; + self.nui.delegate = nil; + self.nui = nil; + } + NeoNuiTts *nui = [NeoNuiTts get_instance]; + nui.delegate = self; + NSInteger code = [nui nui_tts_initialize:[ticket UTF8String] + logLevel:(NuiSdkLogLevel)logLevel + saveLog:saveLog]; + if (code == 0) { + self.nui = nui; + } else { + nui.delegate = nil; + [nui nui_tts_release]; + } + return code; +#else + (void)ticket; + (void)logLevel; + (void)saveLog; + return -1; +#endif +} + +- (NSInteger)setParam:(NSString *)param value:(NSString *)value { +#if ALIYUN_NUI_TTS_AVAILABLE + if (self.nui == nil) { return -1; } + return [self.nui nui_tts_set_param:[param UTF8String] value:[value UTF8String]]; +#else + (void)param; + (void)value; + return -1; +#endif +} + +- (NSInteger)playWithPriority:(NSString *)priority taskId:(NSString *)taskId text:(NSString *)text { +#if ALIYUN_NUI_TTS_AVAILABLE + if (self.nui == nil) { return -1; } + return [self.nui nui_tts_play:[priority UTF8String] taskId:[taskId UTF8String] text:[text UTF8String]]; +#else + (void)priority; + (void)taskId; + (void)text; + return -1; +#endif +} + +- (NSInteger)cancelWithTaskId:(NSString *)taskId { +#if ALIYUN_NUI_TTS_AVAILABLE + if (self.nui == nil) { return -1; } + const char *task = taskId.length > 0 ? [taskId UTF8String] : NULL; + return [self.nui nui_tts_cancel:task]; +#else + (void)taskId; + return -1; +#endif +} + +- (NSInteger)releaseEngine { +#if ALIYUN_NUI_TTS_AVAILABLE + if (self.nui == nil) { return 0; } + NSInteger code = [self.nui nui_tts_release]; + self.nui.delegate = nil; + self.nui = nil; + return code; +#else + return 0; +#endif +} + +- (NSString *)paramValueForKey:(NSString *)key { +#if ALIYUN_NUI_TTS_AVAILABLE + if (self.nui == nil) { return nil; } + const char *value = [self.nui nui_tts_get_param:[key UTF8String]]; + if (value == NULL) { return nil; } + return [NSString stringWithUTF8String:value]; +#else + (void)key; + return nil; +#endif +} + +#if ALIYUN_NUI_TTS_AVAILABLE +- (void)onNuiTtsEventCallback:(NuiSdkTtsEvent)event taskId:(char *)taskid code:(int)code { + NSString *taskId = taskid != NULL ? [NSString stringWithUTF8String:taskid] : nil; + [self.delegate aliyunNuiTTSBridge:self didReceiveEvent:event taskId:taskId code:code]; +} + +- (void)onNuiTtsUserdataCallback:(char *)info infoLen:(int)info_len buffer:(char *)buffer len:(int)len taskId:(char *)task_id { + if (buffer == NULL || len <= 0) { return; } + NSData *data = [NSData dataWithBytes:buffer length:(NSUInteger)len]; + NSString *taskId = task_id != NULL ? [NSString stringWithUTF8String:task_id] : nil; + NSString *infoText = nil; + if (info != NULL && info_len > 0) { + infoText = [[NSString alloc] initWithBytes:info length:(NSUInteger)info_len encoding:NSUTF8StringEncoding]; + } + [self.delegate aliyunNuiTTSBridge:self didReceiveAudioData:data taskId:taskId info:infoText]; +} +#endif + +@end diff --git a/suixinkan/Features/ScenicQueue/Services/AliyunPCMStreamingPlayer.swift b/suixinkan/Features/ScenicQueue/Services/AliyunPCMStreamingPlayer.swift new file mode 100644 index 0000000..38e4ae0 --- /dev/null +++ b/suixinkan/Features/ScenicQueue/Services/AliyunPCMStreamingPlayer.swift @@ -0,0 +1,130 @@ +// +// AliyunPCMStreamingPlayer.swift +// suixinkan +// + +import AVFoundation +import Foundation + +/// TTS PCM 流式播放器协议,便于单元测试替换音频输出。 +protocol AliyunPCMStreamingPlaying: AnyObject { + /// 开始播放指定采样率的 16-bit mono PCM。 + func start(sampleRate: Double) + + /// 写入一段 16-bit little-endian mono PCM 数据。 + func write(_ data: Data) + + /// 通知播放器没有更多数据,已排空后回调。 + func drain(_ completion: @escaping () -> Void) + + /// 立即停止并清空播放队列。 + func stop() +} + +/// 基于 AVAudioEngine 的 16-bit PCM 流式播放器。 +final class AliyunPCMStreamingPlayer: AliyunPCMStreamingPlaying { + private let stateQueue = DispatchQueue(label: "com.zhifly.suixinkan.aliyunNLS.pcmPlayer") + private let engine = AVAudioEngine() + private let playerNode = AVAudioPlayerNode() + private var format: AVAudioFormat? + private var scheduledBufferCount = 0 + private var draining = false + private var drainCompletion: (() -> Void)? + private var started = false + + func start(sampleRate: Double) { + stateQueue.sync { + configureSession() + if engine.attachedNodes.contains(playerNode) == false { + engine.attach(playerNode) + } + let audioFormat = AVAudioFormat(commonFormat: .pcmFormatFloat32, sampleRate: sampleRate, channels: 1, interleaved: false) + format = audioFormat + if let audioFormat { + engine.disconnectNodeOutput(playerNode) + engine.connect(playerNode, to: engine.mainMixerNode, format: audioFormat) + } + if engine.isRunning == false { + try? engine.start() + } + if playerNode.isPlaying == false { + playerNode.play() + } + started = true + draining = false + drainCompletion = nil + } + } + + func write(_ data: Data) { + guard data.count >= 2 else { return } + stateQueue.async { + guard self.started, let format = self.format else { return } + let buffer = Self.makePCMBuffer(from: data, format: format) + guard let buffer else { return } + self.scheduledBufferCount += 1 + self.playerNode.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in + self?.stateQueue.async { + guard let self else { return } + self.scheduledBufferCount = max(0, self.scheduledBufferCount - 1) + self.completeDrainIfNeeded() + } + } + } + } + + func drain(_ completion: @escaping () -> Void) { + stateQueue.async { + self.draining = true + self.drainCompletion = completion + self.completeDrainIfNeeded() + } + } + + func stop() { + stateQueue.sync { + playerNode.stop() + engine.stop() + scheduledBufferCount = 0 + draining = false + drainCompletion = nil + started = false + } + } + + private func completeDrainIfNeeded() { + guard draining, scheduledBufferCount == 0 else { return } + let completion = drainCompletion + drainCompletion = nil + draining = false + started = false + playerNode.stop() + completion?() + } + + private func configureSession() { + let session = AVAudioSession.sharedInstance() + try? session.setCategory(.playback, options: [.allowBluetoothHFP, .mixWithOthers]) + try? session.setActive(true) + } + + private static func makePCMBuffer(from data: Data, format: AVAudioFormat) -> AVAudioPCMBuffer? { + let frameCount = data.count / MemoryLayout.size + guard frameCount > 0, + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frameCount)), + let channel = buffer.floatChannelData?[0] else { + return nil + } + buffer.frameLength = AVAudioFrameCount(frameCount) + data.withUnsafeBytes { raw in + let bytes = raw.bindMemory(to: UInt8.self) + for index in 0.. Int64 } -/// 阿里云 NLS TTS 管理器。 -/// -/// 当前工程未随仓库提供阿里云 iOS NativeNui SDK,类中保留 STS/NLS 预热链路与 SDK 桥接边界; -/// 在 SDK framework 加入后可在 `prepareNativeEngineIfAvailable` 内完成真实 NativeNui 初始化。 +/// 阿里云 NLS TTS 管理器,优先使用 NUI SDK,失败时回退到系统中文语音。 final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynthesizerDelegate { static let shared = AliyunNLSTTSManager() @@ -52,27 +52,50 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth private let synthesizer = AVSpeechSynthesizer() private let loopQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.tts") + private let nativeQueue = DispatchQueue(label: "com.zhifly.suixinkan.scenicQueue.aliyunNLS") + private let appStore: AppStore + private let tokenProvider: AliyunNLSTokenProviding + private let bridge: AliyunNuiTTSBridging + private let streamingPlayer: AliyunPCMStreamingPlaying private var activeLoop: LoopKind? private var currentContinuation: CheckedContinuation? - private var nlsToken: String? + private var nativeContinuation: CheckedContinuation? + private var nlsToken: AliyunNLSToken? private var lastCredentials: AliyunOSSCredentials? + private var nativeReady = false private(set) var customTextLooping = false + let supportsOfflineTTS = false - override init() { + init( + appStore: AppStore = .shared, + tokenProvider: AliyunNLSTokenProviding = AliyunNLSTokenProvider(), + bridge: AliyunNuiTTSBridging = AliyunNuiTTSBridgeAdapter(), + streamingPlayer: AliyunPCMStreamingPlaying = AliyunPCMStreamingPlayer() + ) { + self.appStore = appStore + self.tokenProvider = tokenProvider + self.bridge = bridge + self.streamingPlayer = streamingPlayer super.init() synthesizer.delegate = self + self.bridge.delegate = self } - func prepareQueueTTSEngine(api: ScenicQueueAPI?) async { + func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async { guard let api else { return } + guard !appStore.scenicQueueUseOfflineTTS || supportsOfflineTTS else { + nativeReady = false + return + } do { let sts = try await api.aliyunSTS(bucket: "vipsky") lastCredentials = sts.credentials - nlsToken = nil - prepareNativeEngineIfAvailable(credentials: sts.credentials) + let token = try await tokenProvider.createToken(credentials: sts.credentials) + nlsToken = token + nativeReady = await prepareNativeEngineIfAvailable(token: token) } catch { - // Voice playback falls back to AVSpeechSynthesizer if NLS warm-up fails. + nativeReady = false } } @@ -182,6 +205,35 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth } private func speak(_ text: String) async { + if await speakWithNativeEngine(text) { + return + } + await speakWithSystemVoice(text) + } + + private func speakWithNativeEngine(_ text: String) async -> Bool { + await withCheckedContinuation { continuation in + nativeQueue.async { [weak self] in + guard let self, self.nativeReady, self.bridge.sdkAvailable else { + continuation.resume(returning: false) + return + } + self.cancelNativeSpeakLocked(resumeExisting: true) + self.nativeContinuation = continuation + self.streamingPlayer.stop() + self.streamingPlayer.start(sampleRate: Self.nuiSampleRate) + self.applyNativePlaybackParams(text: text) + let code = self.bridge.play(priority: "1", taskId: Self.nuiTaskId, text: text) + if code != 0 { + self.streamingPlayer.stop() + self.nativeContinuation = nil + continuation.resume(returning: false) + } + } + } + } + + private func speakWithSystemVoice(_ text: String) async { await MainActor.run { if synthesizer.isSpeaking { synthesizer.stopSpeaking(at: .immediate) @@ -200,6 +252,9 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth } private func stopSpeaking() { + nativeQueue.async { [weak self] in + self?.cancelNativeSpeakLocked(resumeExisting: true) + } DispatchQueue.main.async { if self.synthesizer.isSpeaking { self.synthesizer.stopSpeaking(at: .immediate) @@ -221,8 +276,200 @@ final class AliyunNLSTTSManager: NSObject, ScenicQueueTTSManaging, AVSpeechSynth resumeCurrentContinuationIfNeeded() } - private func prepareNativeEngineIfAvailable(credentials: AliyunOSSCredentials) { - _ = NSClassFromString("NativeNui") - _ = credentials + private func prepareNativeEngineIfAvailable(token: AliyunNLSToken) async -> Bool { + guard bridge.sdkAvailable, + let ticket = nativeOnlineTicket(token: token.id) else { + return false + } + return await withCheckedContinuation { continuation in + nativeQueue.async { [weak self] in + guard let self else { + continuation.resume(returning: false) + return + } + self.cancelNativeSpeakLocked(resumeExisting: true) + _ = self.bridge.releaseEngine() + let code = self.bridge.initialize(ticket: ticket, logLevel: 0, saveLog: true) + guard code == 0 else { + continuation.resume(returning: false) + return + } + self.applyNativeQueueProsodyParams() + continuation.resume(returning: true) + } + } + } + + private func nativeOnlineTicket(token: String) -> String? { + guard let workspace = nativeWorkspacePath() else { return nil } + let ticket: [String: String] = [ + "app_key": Self.onlineAppKey, + "token": token, + "workspace": workspace, + "debug_path": nativeDebugPath(), + "url": "wss://nls-gateway.cn-shanghai.aliyuncs.com:443/ws/v1", + "device_id": Self.deviceId(), + "mode_type": "2", + "tts_version": "0", + ] + guard let data = try? JSONSerialization.data(withJSONObject: ticket) else { return nil } + return String(data: data, encoding: .utf8) + } + + private func nativeWorkspacePath() -> String? { + if let privateFrameworksPath = Bundle.main.privateFrameworksPath { + let frameworkPath = (privateFrameworksPath as NSString).appendingPathComponent("nuisdk.framework") + if let frameworkBundle = Bundle(path: frameworkPath), + let path = frameworkBundle.path(forResource: "Resources", ofType: "bundle") { + return path + } + } + return Bundle.main.path(forResource: "Resources", ofType: "bundle") + } + + private func nativeDebugPath() -> String { + let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + let url = (caches ?? URL(fileURLWithPath: NSTemporaryDirectory())).appendingPathComponent("aliyun_nls_tts_debug", isDirectory: true) + try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url.path + } + + private func applyNativePlaybackParams(text: String) { + _ = bridge.setParam("mode_type", value: "2") + _ = bridge.setParam("tts_version", value: text.count > 300 ? "1" : "0") + applyNativeQueueProsodyParams() + } + + private func applyNativeQueueProsodyParams() { + _ = bridge.setParam("speech_rate", value: Self.onlineSpeechRate) + _ = bridge.setParam("volume", value: Self.playbackVolume) + } + + private func cancelNativeSpeakLocked(resumeExisting: Bool) { + _ = bridge.cancel(taskId: Self.nuiTaskId) + streamingPlayer.stop() + if resumeExisting { + finishNativeSpeakLocked(success: false) + } else { + nativeContinuation = nil + } + } + + private func finishNativeSpeakLocked(success: Bool) { + let continuation = nativeContinuation + nativeContinuation = nil + continuation?.resume(returning: success) + } + + private static func deviceId() -> String { + let key = "aliyun_nls_tts_device_id_v1" + if let value = UserDefaults.standard.string(forKey: key), !value.isEmpty { + return value + } + let value = UUID().uuidString + UserDefaults.standard.set(value, forKey: key) + return value + } + + private static let onlineAppKey = "3WX8cD04JCGkbRLi" + private static let nuiTaskId = "1" + private static let nuiSampleRate = 16_000.0 + private static let onlineSpeechRate = "-120" + private static let playbackVolume = "2.0" +} + +/// 阿里云 NUI TTS Swift 桥接协议。 +protocol AliyunNuiTTSBridging: AnyObject { + /// SDK 回调代理。 + var delegate: AliyunNuiTTSBridgeDelegate? { get set } + + /// 当前平台是否可使用 SDK。 + var sdkAvailable: Bool { get } + + /// 初始化引擎。 + func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int + + /// 设置参数。 + func setParam(_ param: String, value: String) -> Int + + /// 开始播放。 + func play(priority: String, taskId: String, text: String) -> Int + + /// 取消任务。 + func cancel(taskId: String?) -> Int + + /// 释放引擎。 + func releaseEngine() -> Int + + /// 读取 SDK 参数。 + func paramValue(for key: String) -> String? +} + +/// 阿里云 NUI TTS Objective-C 桥接器的 Swift 适配器。 +final class AliyunNuiTTSBridgeAdapter: AliyunNuiTTSBridging { + private let bridge: AliyunNuiTTSBridge + + var delegate: AliyunNuiTTSBridgeDelegate? { + get { bridge.delegate } + set { bridge.delegate = newValue } + } + + var sdkAvailable: Bool { bridge.sdkAvailable } + + init(bridge: AliyunNuiTTSBridge = AliyunNuiTTSBridge()) { + self.bridge = bridge + } + + func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int { + bridge.initialize(withTicket: ticket, logLevel: logLevel, saveLog: saveLog) + } + + func setParam(_ param: String, value: String) -> Int { + bridge.setParam(param, value: value) + } + + func play(priority: String, taskId: String, text: String) -> Int { + bridge.play(withPriority: priority, taskId: taskId, text: text) + } + + func cancel(taskId: String?) -> Int { + bridge.cancel(withTaskId: taskId) + } + + func releaseEngine() -> Int { + bridge.releaseEngine() + } + + func paramValue(for key: String) -> String? { + bridge.paramValue(forKey: key) + } +} + +extension AliyunNLSTTSManager: AliyunNuiTTSBridgeDelegate { + func aliyunNuiTTSBridge(_ bridge: AliyunNuiTTSBridge, didReceiveEvent event: Int, taskId: String?, code: Int) { + nativeQueue.async { [weak self] in + guard let self else { return } + switch event { + case 1: + self.streamingPlayer.drain { [weak self] in + self?.nativeQueue.async { + self?.finishNativeSpeakLocked(success: true) + } + } + case 2, 5: + self.streamingPlayer.stop() + self.finishNativeSpeakLocked(success: false) + default: + break + } + _ = taskId + _ = code + } + } + + func aliyunNuiTTSBridge(_ bridge: AliyunNuiTTSBridge, didReceiveAudioData data: Data, taskId: String?, info: String?) { + streamingPlayer.write(data) + _ = taskId + _ = info } } diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift index 83a7527..0c0661f 100644 --- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueSettingsViewModel.swift @@ -103,6 +103,11 @@ final class ScenicQueueSettingsViewModel { return "请选择打卡点" } + /// 是否优先使用离线语音合成。 + var useOfflineTTS: Bool { + appStore.scenicQueueUseOfflineTTS + } + func openSettingChangeLog() { onNavigateLog?() } @@ -170,6 +175,23 @@ final class ScenicQueueSettingsViewModel { notifyStateChange() } + /// 设置离线语音合成偏好。 + func setUseOfflineTTS(_ enabled: Bool) { + guard enabled else { + appStore.scenicQueueUseOfflineTTS = false + notifyStateChange() + return + } + guard ttsManager.supportsOfflineTTS else { + appStore.scenicQueueUseOfflineTTS = false + onShowMessage?("离线语音资源未安装") + notifyStateChange() + return + } + appStore.scenicQueueUseOfflineTTS = true + notifyStateChange() + } + func setCustomTTSText(_ value: String) { customTTSText = String(value.prefix(Limits.remarkMaxLen)) notifyStateChange() diff --git a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift index a2aa12c..448751d 100644 --- a/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift +++ b/suixinkan/Features/ScenicQueue/ViewModels/ScenicQueueViewModel.swift @@ -87,7 +87,7 @@ final class ScenicQueueViewModel { await refreshQueueStats(api: api) await refreshCurrentQueue(api: api, reset: true) await refreshSkippedQueue(api: api, reset: true) - await ttsManager.prepareQueueTTSEngine(api: api as? ScenicQueueAPI) + await ttsManager.prepareQueueTTSEngine(api: api) } else { resetQueues() onRequireSettings?() diff --git a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift index a3f4ce4..1d064e8 100644 --- a/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift +++ b/suixinkan/UI/ScenicQueue/ScenicQueueSettingsViewController.swift @@ -166,6 +166,7 @@ final class ScenicQueueSettingsViewController: BaseViewController { private func buildVoiceForm() { addTextRow(key: "broadcastInterval", title: "播报间隔时间", value: "\(viewModel.broadcastIntervalSec)", suffix: "秒", keyboard: .numberPad, action: #selector(broadcastChanged(_:))) addTextRow(key: "countdown", title: "进入读秒倒计时阈值", value: "\(viewModel.countdownThresholdSec)", suffix: "秒", keyboard: .numberPad, action: #selector(countdownChanged(_:))) + addSwitchRow(key: "offlineTTS", title: "离线语音合成", isOn: viewModel.useOfflineTTS, action: #selector(offlineTTSChanged(_:))) stackView.addArrangedSubview(actionCard(title: "蓝牙音响测试", value: "请确认外放音量是否合适", primary: "播放测试音", secondary: nil, primaryAction: #selector(testSoundTapped), secondaryAction: nil)) addTextRow(key: "customTTS", title: "自定义播报文案", value: viewModel.customTTSText, suffix: "", keyboard: .default, action: #selector(customTTSChanged(_:))) stackView.addArrangedSubview(actionCard(title: "播报文案", value: "最多保存5个语音", primary: "播放文本", secondary: "保存到预设", primaryAction: #selector(playCustomTapped), secondaryAction: #selector(savePresetTapped))) @@ -329,6 +330,7 @@ final class ScenicQueueSettingsViewController: BaseViewController { @objc private func secondAheadChanged(_ sender: UITextField) { viewModel.setSecondAheadCount(Int(sender.text ?? "") ?? 0) } @objc private func broadcastChanged(_ sender: UITextField) { viewModel.setBroadcastIntervalSec(Int(sender.text ?? "") ?? 50) } @objc private func countdownChanged(_ sender: UITextField) { viewModel.setCountdownThresholdSec(Int(sender.text ?? "") ?? 15) } + @objc private func offlineTTSChanged(_ sender: UISwitch) { viewModel.setUseOfflineTTS(sender.isOn) } @objc private func customTTSChanged(_ sender: UITextField) { viewModel.setCustomTTSText(sender.text ?? "") } } diff --git a/suixinkan/suixinkan-Bridging-Header.h b/suixinkan/suixinkan-Bridging-Header.h index bd5a286..2951b24 100644 --- a/suixinkan/suixinkan-Bridging-Header.h +++ b/suixinkan/suixinkan-Bridging-Header.h @@ -3,11 +3,26 @@ // suixinkan // +#if __has_include() #import +#endif + +#if __has_include() #import +#endif + +#if __has_include() #import +#endif + +#if __has_include() #import +#endif + +#if __has_include() #import +#endif +#import "Features/ScenicQueue/Services/AliyunNuiTTSBridge.h" #if __has_include() #import diff --git a/suixinkanTests/ScenicQueueFeatureTests.swift b/suixinkanTests/ScenicQueueFeatureTests.swift index 8a66a92..284aa48 100644 --- a/suixinkanTests/ScenicQueueFeatureTests.swift +++ b/suixinkanTests/ScenicQueueFeatureTests.swift @@ -271,6 +271,68 @@ final class ScenicQueueFeatureTests: XCTestCase { XCTAssertEqual(store.scenicQueuePunchSpotId, "") XCTAssertNil(store.scenicQueueSettingsSnapshot()) } + + func testAliyunNLSTokenProviderBuildsSignedCreateTokenRequest() async throws { + let credentials = try MockScenicQueueAPI.makeSTS().credentials + let provider = AliyunNLSTokenProvider( + dateProvider: { Date(timeIntervalSince1970: 1_704_067_200) }, + nonceProvider: { "nonce-1" }, + dataLoader: { request in + let url = try XCTUnwrap(request.url) + let query = try XCTUnwrap(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + XCTAssertEqual(query.first(where: { $0.name == "Action" })?.value, "CreateToken") + XCTAssertEqual(query.first(where: { $0.name == "AccessKeyId" })?.value, "ak") + XCTAssertEqual(query.first(where: { $0.name == "SecurityToken" })?.value, "security-token") + XCTAssertEqual(query.first(where: { $0.name == "SignatureMethod" })?.value, "HMAC-SHA1") + XCTAssertNotNil(query.first(where: { $0.name == "Signature" })?.value) + return #"{"Token":{"Id":"nls-token","ExpireTime":1704068200}}"#.data(using: .utf8)! + } + ) + + let token = try await provider.createToken(credentials: credentials) + + XCTAssertEqual(token.id, "nls-token") + XCTAssertEqual(token.expireTime, 1_704_068_200) + } + + func testAliyunTTSManagerUsesNativeBridgeAndPlayerWhenPrepared() async { + let api = MockScenicQueueAPI() + let tokenProvider = MockAliyunNLSTokenProvider() + let bridge = MockAliyunNuiTTSBridge() + let player = MockAliyunPCMStreamingPlayer() + let manager = AliyunNLSTTSManager( + tokenProvider: tokenProvider, + bridge: bridge, + streamingPlayer: player + ) + + await manager.prepareQueueTTSEngine(api: api) + let ok = await manager.speakPlainTextOnce("A12号,请到拍摄区域,准备拍摄。") + + XCTAssertTrue(ok) + XCTAssertTrue(bridge.events.contains { $0.hasPrefix("initialize:") }) + XCTAssertTrue(bridge.events.contains("set:speech_rate=-120")) + XCTAssertTrue(bridge.events.contains("set:volume=2.0")) + XCTAssertTrue(bridge.events.contains("play:A12号,请到拍摄区域,准备拍摄。")) + XCTAssertEqual(Array(player.events.suffix(3)), ["start:16000", "write:4", "drain"]) + } + + func testSettingsOfflineTTSSwitchReportsUnavailable() { + let defaults = UserDefaults(suiteName: "ScenicQueueFeatureTests.offlineTTS")! + defaults.removePersistentDomain(forName: "ScenicQueueFeatureTests.offlineTTS") + let store = AppStore(defaults: defaults) + store.userId = "u1" + store.accountType = "photog" + let tts = MockScenicQueueTTSManager() + let viewModel = ScenicQueueSettingsViewModel(appStore: store, ttsManager: tts) + var messages: [String] = [] + viewModel.onShowMessage = { messages.append($0) } + + viewModel.setUseOfflineTTS(true) + + XCTAssertFalse(store.scenicQueueUseOfflineTTS) + XCTAssertEqual(messages.last, "离线语音资源未安装") + } } @MainActor @@ -355,8 +417,9 @@ private final class MockScenicQueueAPI: ScenicQueueAPIProtocol { private final class MockScenicQueueTTSManager: ScenicQueueTTSManaging { private(set) var events: [String] = [] private(set) var customTextLooping = false + let supportsOfflineTTS = false - func prepareQueueTTSEngine(api: ScenicQueueAPI?) async { + func prepareQueueTTSEngine(api: ScenicQueueAPIProtocol?) async { events.append("prepare") } @@ -393,6 +456,76 @@ private final class MockScenicQueueTTSManager: ScenicQueueTTSManaging { } } +private final class MockAliyunNLSTokenProvider: AliyunNLSTokenProviding { + func createToken(credentials: AliyunOSSCredentials) async throws -> AliyunNLSToken { + AliyunNLSToken(id: "mock-nls-token", expireTime: 1_704_068_200) + } +} + +private final class MockAliyunNuiTTSBridge: AliyunNuiTTSBridging { + weak var delegate: AliyunNuiTTSBridgeDelegate? + var sdkAvailable = true + private(set) var events: [String] = [] + private let callbackBridge = AliyunNuiTTSBridge() + + func initialize(ticket: String, logLevel: Int, saveLog: Bool) -> Int { + events.append("initialize:\(ticket.contains("mock-nls-token"))") + return 0 + } + + func setParam(_ param: String, value: String) -> Int { + events.append("set:\(param)=\(value)") + return 0 + } + + func play(priority: String, taskId: String, text: String) -> Int { + events.append("play:\(text)") + delegate?.aliyunNuiTTSBridge( + callbackBridge, + didReceiveAudioData: Data([0, 0, 1, 0]), + taskId: taskId, + info: nil + ) + delegate?.aliyunNuiTTSBridge(callbackBridge, didReceiveEvent: 1, taskId: taskId, code: 0) + return 0 + } + + func cancel(taskId: String?) -> Int { + events.append("cancel:\(taskId ?? "")") + return 0 + } + + func releaseEngine() -> Int { + events.append("release") + return 0 + } + + func paramValue(for key: String) -> String? { + nil + } +} + +private final class MockAliyunPCMStreamingPlayer: AliyunPCMStreamingPlaying { + private(set) var events: [String] = [] + + func start(sampleRate: Double) { + events.append("start:\(Int(sampleRate))") + } + + func write(_ data: Data) { + events.append("write:\(data.count)") + } + + func drain(_ completion: @escaping () -> Void) { + events.append("drain") + completion() + } + + func stop() { + events.append("stop") + } +} + private extension ScenicQueueHomeData { static func page(items: [ScenicQueueHomeItem], total: Int, page: Int) -> ScenicQueueHomeData { ScenicQueueHomeData(