iOS开发实战 — 集成声网语音通话+微软实时翻译

987 阅读8分钟

背景:随着短视频的兴起,应用内含有音视频基本都成了标配。因此也就有了本次需求: 实现直播间拉流过程,实时翻译成本地语言字幕。(比如我中文在这直播,美国人在看就显示英文字幕,韩国人在看他就显示韩文字幕)

调研:

  • 直播 —— 市面上都是基于FFmpeg,直接对接这有难度,就选了封装了的IJKPlayer
  • 语音通话 —— 调研了声网SDK,每个账户给了1000分钟免费测试时间,还可以
  • 实时翻译 —— 调研了微软识别翻译SDK

Demo测试:

  1. IJKPlayer 编译,详细见前面文章: iOS编译IJKPlayer详细步骤
  2. 声网实现语音通话,也非常简单,直接看声网文档即可,这就不多说了。
  3. 微软实时翻译,只能看微软官方文档。 What is the Speech SDK? (这里必须要多说下,微软的文档写的是真的简单,特别是iOS相关的,基本就没有,连Demo都不提供,都要去看参考其他语言来猜iOS的API)

Demo整体实现:

在声网语音通话代理方法回调中,获取到音频流数据(声网这边直接给了void *buffer),然后将这个音频流给到微软SDK那边,实时连续进行翻译,最后输出结果。

对接中的疑惑:

  • 声网回调方法每秒钟要回调好多次,怎么把这个音频流和微软翻译这个实时流对应起来?
  • 声网这边返回了void *buffer数据,怎么转成微软需要的数据格式?
  • 微软SDK翻译初始化SPXPullAudioInputStream只有一个初始化Block方法回调,怎么进行持续识别翻译?
  • 网上相关的资料几乎没有,外加微软这文档、Demo只是简单的使用,根本没有我这边需要的场景,毫无头绪啊。。。

开始:

  • 万事开头难,写吧,写着写着可能就清晰了。然后参考Android的实现方案,硬着头皮挪吧。 还有问题就去微软官方github提issue问,我新开了两个issue啊,还好他们回复的还算及时(虽然说他们不明白我问的啥意思,回复的东西我也用不上[doge])。

皇天不负有心人,终于找到了一个关于音频流相关的文档, How to use the audio input stream,但依然是只有C#语言,iOS相关无。只能去硬着头皮找类的API。这个iOS上真的没有PullAudioInputStreamCallback回调以及read() close()方法啊啊啊啊。。根据这个文档说其他语言方法名字应该雷同,我就去找啊,发现一个方法,很像,越看注释越像,就开始使劲搞。(后来才证实,这特么是错误的引导!!!)

/**
 * Represents an audio input stream used for custom audio input configurations.
 */
SPX_EXPORT
@interface SPXPullAudioInputStream : SPXAudioInputStream
/**
 * Initializes an SPXPullAudioInputStream that delegates to the specified callback interface for read() and close() methods, using the default format (16 kHz, 16 bit, mono PCM).
 *
 * @param readHandler handler which will be called in order to read data from the audio input stream. If no data is currently available in the stream, the readHandler should wait until data is available. It returns the number of bytes that have been read. It returns 0 when the stream should be closed. The data returned by read() should not contain any audio header.
 * @param closeHandler handler which will be called in order to close the audio input stream.
 * @return an instance of pull audio input stream.
 */
- (nullable instancetype)initWithReadHandler:(nonnull SPXPullAudioInputStreamReadHandler)readHandler closeHandler:(nonnull SPXPullAudioInputStreamCloseHandler)closeHandler;

当我看到这个类以及初始化方法时候,以为找到了,还好个开心呢,然后就根据示例往上凑啊凑:

// 获取音频流进行识别
- (void)spxAudioRecgnize {
//        SPXAudioStreamFormat *famt = [[SPXAudioStreamFormat alloc] initUsingPCMWithSampleRate:16000 bitsPerSample:1024 channels:1];
    //    AVAudioFormat *format = [[AVAudioFormat alloc] initWithCommonFormat:AVAudioPCMFormatInt16 sampleRate:frame.samplesPerSec channels:frame.channels interleaved:NO];
    SPXPullAudioInputStream *inputStream = [[SPXPullAudioInputStream alloc] initWithReadHandler:^NSInteger(NSMutableData * data, NSUInteger size) {
        NSLog(@"data === %@;;  size = %ld", data, size);
        AVAudioPCMBuffer *buffer = [[AVAudioPCMBuffer alloc] initWithPCMFormat:audioFile.fileFormat frameCapacity:(AVAudioFrameCount) size / bytesPerFrame];
                                NSError *bufferError = nil;
                                bool success = [audioFile readIntoBuffer:buffer error:&bufferError];
                                NSInteger nBytes = 0;
                                if (!success) {
                                    // returns 0 to close the stream on read error.
                                    NSLog(@"Read error on stream: %@", bufferError);
                                }
                                else
                                {
                                    // number of bytes in the buffer
                                    nBytes = [buffer frameLength] * bytesPerFrame;
                                    NSRange range;
                                    range.location = 0;
                                    range.length = nBytes;
                                    NSAssert(1 == buffer.stride, @"only one channel allowed");
                                    NSAssert(nil != buffer.int16ChannelData, @"assure correct format");

                                    [data replaceBytesInRange:range withBytes:buffer.int16ChannelData[0]];
                                    NSLog(@"%d bytes data returned", (int)[data length]);
                                }
                                // returns the number of bytes that have been read, 0 closes the stream.
                                return nBytes;

    } closeHandler:^{

    }];

什么formatpullStramPCMBuffer啊啥的,凑了好久,这不对劲啊。 这个只是一个初始化方法,block回调是已经返回的数据流,而不是我给它传我已经有的音频流啊,这不对劲。。 难道用pullStream不对么?? 一天又过去了。。

然后去Github上找到iOS相关的Demo,Azure-Samples/cognitive-services-speech-sdk, 但是这边都是从本地音频文件来做的翻译。没有读取实时音频流的方式啊。

然后就参考Android方案,在声网音频流回调方法里,将得到的音频数据void *buffercopy存储在一个队列里,然后在微软识别SDK方法里逐次取出。(这里就又有问题了:声网回调一分钟调用好多次,微软识别这个callback回调次数怎么跟声网这个频率对上? 每次取值只需了队列最前那个值,用完还将其清除了。这要是对不上,那不是丢数据了,声音应该不全或者卡卡的吧。还有iOS这边没有对应的回调方法,以及read write 数据流的方法,啥时候读取未知。)

看来Android 方案有些行不通,找别的吧,继续提issue问官方,看官方给出了参考Demo:Speech_sameples 。 在这里找到了一个拉流的类SPXPullAudioInputStream,但是细看下来还是从本地音频文件,然后转成的流数据,最后做的处理。 那就想办法把这个声网回来的void *buffer音频流数据转成它需要的。想法很好,过程很艰难,最后也没能转好。(最后才发现,原来是方向错了,根本不能用pullStream)

最后的实现:

  • 使用推流SPXPushAudioInputStream可以。
  • 在声网回调音频流方法里,将void *buffer直接存在这个SPXPushAudioInputStream
  • 在微软识别翻译SDK方法中,使用pushStream的方式,就可以完成这个识别翻译操作了。

现在回头来看真的好简单的,但是最开始没有头绪、外加上参考方向、文档缺失误导、想法都错了真的走了很多弯路,踩了很多坑啊。

主要代码如下:
// 初始化声网SDK,设置代理
- (void)initAgoraKit {
    self.agoraKit = [AgoraRtcEngineKit sharedEngineWithAppId:@"APPID" delegate:self];
    // 获取原始音频数据代理
    [self.agoraKit setAudioDataFrame:self];
}
// 音频代理方法 实现一套就好!!!
//返回值中设置想要观测的音频位置和是否获取多个频道的音频数据。
- (AgoraAudioFramePosition)getObservedAudioFramePosition {
    return AgoraAudioFramePositionRecord; // 本地
    //    return AgoraAudioFramePositionPlayback; // 其他人- 要和其他两个方法对应上
}
// 获取自己流 - 如果你不对这个音频数据做进一步的处理,要返回NONONO!!!!
- (BOOL)onRecordAudioFrame:(AgoraAudioFrame *)frame {
    // 将数据保存到pushStream流中
    NSData *bufferData = [NSData dataWithBytes:frame.buffer length:frame.channels * frame.bytesPerSample * frame.samplesPerChannel];
    // 这个pushStream要设置全局的,和微软识别SDK里用到pushStream要是一个!!!
    [self.pushStream write:bufferData];
    return NO;
}
// 获取通话流
- (BOOL)onPlaybackAudioFrame:(AgoraAudioFrame *)frame {
    // 将数据保存到pushStream流中
    NSData *bufferData = [NSData dataWithBytes:frame.buffer length:frame.channels * frame.bytesPerSample * frame.samplesPerChannel];
    // 这个pushStream要设置全局的,和微软识别SDK里用到pushStream要是一个!!!
    [self.pushStream write:bufferData];
    return  NO;
}
//在这些回调的返回值中设置想要获取的音频数据格式- 实现一个就好了。。
- (AgoraAudioParam *)getRecordAudioParams {
    AgoraAudioParam *audio = [AgoraAudioParam new];
    audio.sampleRate = 16000;
    audio.channel = 1;
    audio.mode = AgoraAudioRawFrameOperationModeReadOnly;
    audio.samplesPerCall = 1024;
    return  audio;
}
- (AgoraAudioParam * _Nonnull)getPlaybackAudioParams {
    AgoraAudioParam *audio = [AgoraAudioParam new];
    audio.sampleRate = 16000;
    audio.channel = 1;
    audio.mode = AgoraAudioRawFrameOperationModeReadOnly;
    audio.samplesPerCall = 1024;
    return  audio;
}

以上声网SDK需要处理的主要方法就完成了,接下来就是微软这边的了。

// 加入语音频道 -声网语音通话
- (void)joinChannel {
    NSInteger result = [self.agoraKit joinChannelByToken:@"token" channelId:@"channel" info:nil uid:0 joinSuccess:^(NSString * _Nonnull channel, NSUInteger uid, NSInteger elapsed) {
        NSLog(@"加入频道成功!频道:%@,UID=%ld,elapse=%ld", channel, uid, elapsed);
        // 在加入语音频道成功后,就初始化微软识别翻译方法
        [self pushAudioInputStream];
    }];
    if (result != 0) {
        NSLog(@"加入频道错误了");
    }
}

- (void)pushAudioInputStream {
    // 这里的参数要注意哈,第一个第三个,要和声网回调方法里音频设置的一致!!!
    // 第二个参数暂时用16吧,用更大的会crash,其他的貌似也不行。  不要问为哈是16,问就是不记得在哪个文档看到的了,再就是多次测试试出来的!!!
    SPXAudioStreamFormat *streamFormat = [[SPXAudioStreamFormat alloc] initUsingPCMWithSampleRate:16000 bitsPerSample:16 channels:1];

    SPXSpeechConfiguration *speechConfig = [[SPXSpeechConfiguration alloc] initWithSubscription:speechKey region:serviceRegion];
    if (!speechConfig) {
        NSLog(@"Could not load speech config");
        [self updateRecognitionErrorText:(@"Speech Config Error")];
        return;
    }
    // 全局变量,和在声网回调方法里存void *buffer的是一个。。。
    self.pushStream = [[SPXPushAudioInputStream alloc] initWithAudioFormat:streamFormat];
    SPXAudioConfiguration *audioConfig = [[SPXAudioConfiguration alloc]initWithStreamInput:self.pushStream];

    //SPXSpeechRecognizer 这个也要用全局方法,不然不能连续识别。。 这也是文档的坑。。。
    self.recognize = [[SPXSpeechRecognizer alloc] initWithSpeechConfiguration:speechConfig audioConfiguration:audioConfig];
    if (!self.recognize) {
        NSLog(@"Could not create speech recognizer");
        [self updateRecognitionResultText:(@"Speech Recognition Error")];
        return;
    }
    // 连续识别
    [self.recognize startContinuousRecognition];
   
    [self updateRecognitionStatusText:(@"Assessing...")];
    
    // 对应的监听方法
    [self.recognize addRecognizingEventHandler:^(SPXSpeechRecognizer *recgnize, SPXSpeechRecognitionEventArgs * eventArgs) {
        NSLog(@"Received intermediate result event. SessionId: %@, recognition result:%@. Status %ld. offset %llu duration %llu resultid:%@", eventArgs.sessionId, eventArgs.result.text, (long)eventArgs.result.reason, eventArgs.result.offset, eventArgs.result.duration, eventArgs.result.resultId);
    }];

    [self.recognize addRecognizedEventHandler:^(SPXSpeechRecognizer * recgnize, SPXSpeechRecognitionEventArgs * eventArgs) {
        NSLog(@"Received final result event. SessionId: %@, recognition result:%@. Status %ld. offset %llu duration %llu resultid:%@", eventArgs.sessionId, eventArgs.result.text, (long)eventArgs.result.reason, eventArgs.result.offset, eventArgs.result.duration, eventArgs.result.resultId);
        [self updateRecognitionStatusText:eventArgs.result.text];
    }];
}

以上就是实现语音通话,然后实时识别通话内容,将其翻译成对应语言的功能主要方法。