处于项目需要,我研究了一下web端的语音识别实现。目前市场上语音服务已经非常成熟了,国内的科大讯飞或是国外的微软在这块都可以提供足够优质的服务,对于我们工程应用来说只需要花钱调用接口就行了,难点在于整体web应用的开发。最开始我实现了一个web端录好音然后上传服务端进行语音识别的简单demo,但是这种结构太过简单,对浏览器的负担太重,而且响应慢,交互差;后来经过调研,发现微软的语音服务接口是支持流输入的连续识别的,因此开发重点就在于实现前后端的流式传输。
参考这位国外大牛写的博文Continuous speech to text on the server with Cognitive Services,开发的新demo可以达到理想的效果,在网页上点击“开始录音”开启一次录音,对着麦克风随意说话,网页会把实时的音频数据传递给后端,后端实时识别并返回转换结果,点击“结束录音”停止。

1 整体结构

整体结构图如下所示,在web端需要使用HTML5的Web Audio API接收麦克风输入的音频流,进行适量的处理后实时传递给服务端;web与服务端之间的音频流交互通过SignalR来实现;具体的语音识别通过调用微软语音服务实现。
该web实时语音识别demo可以实现下面的功能:

  • 可以通过网页传入麦克风音频
  • 网页可以实时显示语音识别结果,包括中间结果和最终结果
  • 可以保存每一次的录音,并且录音时长可以非常长
  • 支持多个web同时访问,服务端管理多个连接

2 技术栈

  • ASP.NET Core开发
    服务端使用较新的 ASP.NET Core技术开发,不同于传统的 ASP.NET,ASP.NET Core更加适合前后台分离的web应用,我们会用 ASP.NET Core框架开发REST API为前端服务。如果不去纠结 ASP.NET Core的框架结构,实际的开发和之前的.NET应用开发没什么不同,毕竟只是底层结构不同。
  • JavaScript或TypeScript开发
    前端的逻辑用JavaScript开发,具体什么框架无所谓。我是用angular开发的,因此严格的说开发语言是TypeScript了。至于网页的具体内容,熟悉html、CSS就行了。
  • Web Audio API的使用和基本的音频处理的知识
    采集和上传麦克风音频都在浏览器进行,对此需要使用HTML5标准下的Web API进行音频流的获取,使用音频上下文(AudioContext)实时处理音频流。具体处理音频流时,需要了解一点基本的音频知识,例如采样率、声道等参数,WAV文件格式等等。
    相关资料:
    HTML5网页录音和压缩
    HTML5 getUserMedia/AudioContext 打造音谱图形化
    Capturing Audio & Video in HTML5
  • 微软语音认知服务
    微软的语音识别技术是微软云服务中的成员之一,相比于国内比较熟知的科大讯飞,微软的优势在于契合 .NET Core技术栈,开发起来非常方便,支持连续识别,支持自定义训练,并且支持容器部署,这对于那些对上传云服务有安全顾虑的用户更是好消息。当然价格考虑就得看具体情况了,不过如果你有Azure账号的话,可以开通标准版本的语音识别服务,这是免费的,只有时间限制;没有账号的话可以使用微软提供的体验账号体验一个月。
    官方文档: Speech Services Documentation
  • SignalR的使用
    要实现web和服务端的流通信,就必须使用web socket一类的技术来进行长连接通信,微软的SignalR是基于web socket的实时通信技术,如果我们的web需要和服务端保持长连接或者需要接收服务端的消息推送,使用该技术可以方便的实现。需要注意的是 ASP.NET SignalR and ASP.NET Core SignalR是有区别的,在 .NET Core环境下需要导入的是SignalR Core
    官方文档: Introduction to ASP.NET Core SignalR
    Angular下调用SignalR: How to Use SignalR with .NET Core and Angular
  • 字节流和异步编程的概念
    参考这篇博文Continuous speech to text on the server with Cognitive Services,在服务端需要自己实现一个特别的字节流来作为语音服务的数据源,因为语音服务在默认的字节流上一旦读取不到数据就会自动结束。在具体的实现中,将会用到一些信号量来进行读取控制。

3 后端细节

3.1 获取微软语音识别服务

如果没有Azure账号,可以用微软提供的试用账号:

有Azure账号的话,在Azure门户里开通语音识别服务


创建的时候可以选择F0类型的收费标准,这种是免费的:


开通成功后,得到API Key的值,我们调用服务的时候传入这个参数;另一个参数是region,这个要看你创建服务的时候选择的区域,如果你选择的是East Asia,这个参数就是“eastasia”,如果用的是测试账号,统一用“westus”。

3.2 创建并配置ASP.NET Core API项目

新建一个 ASP.NET Core API项目

通过Nuget管理器添加语音服务和SignalR相关的包。Microsoft.CognitiveServices.Speech是微软语音服务包,Microsoft.AspNetCore.SignalR.Protocols.MessagePack用于SignalR中的MessagePack协议通信。

在Startup.cs中为 ASP.NET Core项目注入并配置SignalR服务:

        public void ConfigureServices(IServiceCollection services){services.AddCors(options =>{options.AddPolicy("CorsPolicy",builder => builder.WithOrigins("http://localhost:4200").AllowAnyMethod().AllowAnyHeader().AllowCredentials());}); // 跨域请求设置services.AddSignalR().AddMessagePackProtocol(options =>{options.FormatterResolvers = new List<MessagePack.IFormatterResolver>(){MessagePack.Resolvers.StandardResolver.Instance};}); // 允许signalR以MessagePack消息进行通信services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2);}// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.public void Configure(IApplicationBuilder app, IHostingEnvironment env){if (env.IsDevelopment()){app.UseDeveloperExceptionPage();}else{// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.app.UseHsts();}app.UseHttpsRedirection();app.UseCors("CorsPolicy"); //添加跨域请求服务app.UseSignalR(routes =>{routes.MapHub<ContinuousS2TAPI.S2THub.S2THub>("/s2thub");}); //添加SignalR服务并配置路由,访问'/s2thub'将被映射到S2THub对象上app.UseMvc();}

3.3 SignalR接口

新建一个S2THub文件夹,将SignalR接口放着里面。先创建一个Connection类,它代表一个客户端连接:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CognitiveServices.Speech;
using ContinuousS2TAPI.Speech;namespace ContinuousS2TAPI.S2THub
{public class Connection{public string SessionId; // 会话IDpublic SpeechRecognizer SpeechClient; // 一个语音服务对象public VoiceAudioStream AudioStream; //代表一个音频流public List<byte> VoiceData; //存储该次会话的音频数据}
}

然后创建继承自Hub的S2THub类,这个实例化的S2THub对象将会管理客户端的连接

    public class S2THub : Hub{private static IConfiguration _config;private static IHubContext<S2THub> _hubContext; //S2THub实例的上下文private static Dictionary<string, Connection> _connections; //维护客户端连接public S2THub(IConfiguration configuration, IHubContext<S2THub> ctx){if (_config == null)_config = configuration;if (_connections == null)_connections = new Dictionary<string, Connection>();if (_hubContext == null)_hubContext = ctx;}......}

在S2THub类中,需要定义两个供客户端调用的接口AudioStart和ReceiveAudio,客户端首先需要通过调用AudioStart来通知服务端开始一次语音识别会话,接着在接收到实时的音频二进制数据后调用ReceiveAudio接口来将数据发送给服务端。这里会用到一个名为VoiceAudioStream的流对象,这是我们自定义的流对象,具体实现后文给出。

        public async void AudioStart(){Console.WriteLine($"Connection {Context.ConnectionId} starting audio.");var audioStream = new VoiceAudioStream(); // 创建一个供语音识别对象读取的数据流var audioFormat = AudioStreamFormat.GetWaveFormatPCM(16000, 16, 1);var audioConfig = AudioConfig.FromStreamInput(audioStream, audioFormat);var speechConfig = SpeechConfig.FromSubscription("your key", "you region"); //使用你自己的key和region参数speechConfig.SpeechRecognitionLanguage = "zh-CN"; //中文var speechClient = new SpeechRecognizer(speechConfig, audioConfig);speechClient.Recognized += _speechClient_Recognized; // 连续识别存在Recognized和Recognizing事件speechClient.Recognizing += _speechClient_Recognizing;speechClient.Canceled += _speechClient_Canceled;string sessionId = speechClient.Properties.GetProperty(PropertyId.Speech_SessionId);var conn = new Connection(){SessionId = sessionId,AudioStream = audioStream,SpeechClient = speechClient,VoiceData = new List<byte>()};_connections.Add(Context.ConnectionId, conn); //将这个新的连接记录await speechClient.StartContinuousRecognitionAsync(); //开始连续识别Console.WriteLine("Audio start message.");}public void ReceiveAudio(byte[] audioChunk){//Console.WriteLine("Got chunk: " + audioChunk.Length);_connections[Context.ConnectionId].VoiceData.AddRange(audioChunk); //记录接收到的音频数据_connections[Context.ConnectionId].AudioStream.Write(audioChunk, 0, audioChunk.Length);//并将实时的音频数据写入流}

要将识别的结果返回给客户端,就需要调用客户端的接口实现推送,因此定义一个SendTranscript方法,内部会调用客户端名为IncomingTranscript的接口来推送消息:

        public async Task SendTranscript(string text, string sessionId){var connection = _connections.Where(c => c.Value.SessionId == sessionId).FirstOrDefault(); await _hubContext.Clients.Client(connection.Key).SendAsync("IncomingTranscript", text); //调用指定客户端的IncomingTranscript接口}

在语音服务的识别事件中我们调用SendTranscript方法返回结果:

        private async void _speechClient_Canceled(object sender, SpeechRecognitionCanceledEventArgs e){Console.WriteLine("Recognition was cancelled.");if (e.Reason == CancellationReason.Error){Console.WriteLine($"CANCELED: ErrorCode={e.ErrorCode}");Console.WriteLine($"CANCELED: ErrorDetails={e.ErrorDetails}");Console.WriteLine($"CANCELED: Did you update the subscription info?");await SendTranscript("识别失败!", e.SessionId);}}private async void _speechClient_Recognizing(object sender, SpeechRecognitionEventArgs e){Console.WriteLine($"{e.SessionId} > Intermediate result: {e.Result.Text}");await SendTranscript(e.Result.Text, e.SessionId);}private async void _speechClient_Recognized(object sender, SpeechRecognitionEventArgs e){Console.WriteLine($"{e.SessionId} > Final result: {e.Result.Text}");await SendTranscript(e.Result.Text, e.SessionId);}

最后我们重写Hub的OnDisconnectedAsync方法,这个方法会在hub连接断开时调用,可以在该方法内结束识别:

        public async override Task OnDisconnectedAsync(Exception exception){var connection = _connections[Context.ConnectionId];Console.WriteLine($"Voice list length : {connection.VoiceData.Count}");byte[] actualLength = System.BitConverter.GetBytes(connection.VoiceData.Count);string rootDir = AppContext.BaseDirectory;System.IO.DirectoryInfo directoryInfo = System.IO.Directory.GetParent(rootDir);string root = directoryInfo.Parent.Parent.FullName;var savePath = $"{root}\\voice{connection.SessionId}.wav";using (var stream = new System.IO.FileStream(savePath, System.IO.FileMode.Create)) // 保存音频文件{byte[] bytes = connection.VoiceData.ToArray();bytes[4] = actualLength[0];bytes[5] = actualLength[1];bytes[6] = actualLength[2];bytes[7] = actualLength[3];bytes[40] = actualLength[0];bytes[41] = actualLength[1];bytes[42] = actualLength[2];bytes[43] = actualLength[3]; // 计算并填入音频数据长度stream.Write(bytes, 0, bytes.Length);}await connection.SpeechClient.StopContinuousRecognitionAsync(); //结束识别connection.SpeechClient.Dispose();connection.AudioStream.Dispose();_connections.Remove(Context.ConnectionId); //移除连接记录Console.WriteLine($"connection : {Context.ConnectionId} closed");await base.OnDisconnectedAsync(exception);}

3.4 自定义音频流

SpeechRecognizer对象可以接收一个特殊的流对象PullAudioStreamCallback作为数据源,如果传入了这个对象,SpeechRecognizer会主动从该流对象里读取数据。这个一个虚类,如果你只是给一段音频文件做识别,通过MemoryStream和BinaryStreamReader的简单组合就可以了(可以看微软的官方demo),但是SpeechRecognizer会在流中读取到0个字节后停止识别,在我们的场景中默认的流类型无法满足需求,当没有数据读取到时它们无法block住,PullAudioStreamCallback期望的效果是只有当明确流结束时读取流的Read()方法才返回0。因此需要定义我们自己的音频流对象
首先定义一个继承自MemoryStream的EchoStream, 该流对象会在没有数据进入时进行等待而不是直接返回0

    public class EchoStream:MemoryStream{private readonly ManualResetEvent _DataReady = new ManualResetEvent(false);private readonly ConcurrentQueue<byte[]> _Buffers = new ConcurrentQueue<byte[]>();public bool DataAvailable { get { return !_Buffers.IsEmpty; } }public override void Write(byte[] buffer, int offset, int count){_Buffers.Enqueue(buffer.Take(count).ToArray());_DataReady.Set();}public override int Read(byte[] buffer, int offset, int count){//Debug.WriteLine("Data available: " + DataAvailable);_DataReady.WaitOne();byte[] lBuffer;if (!_Buffers.TryDequeue(out lBuffer)){_DataReady.Reset();return -1;}if (!DataAvailable)_DataReady.Reset();Array.Copy(lBuffer, buffer, lBuffer.Length);return lBuffer.Length;}}

然后定义PullAudioStreamCallback对象,作为语音服务的输入源。服务端会把客户端上传的byte[]数据通过Write()方法写入流,而语音服务会调用Read()方法读取数据,可以看到,通过一个ManualResetEvent信号量,使得流对象必须在调用Close()方法之后才会在Read()方法中返回0

    public class VoiceAudioStream : PullAudioInputStreamCallback{private readonly EchoStream _dataStream = new EchoStream();private ManualResetEvent _waitForEmptyDataStream = null;public override int Read(byte[] dataBuffer, uint size) //S2T服务从PullAudioInputStream中读取数据, 读到0个字节并不会关闭流{if (_waitForEmptyDataStream != null && !_dataStream.DataAvailable){_waitForEmptyDataStream.Set();return 0;}return _dataStream.Read(dataBuffer, 0, dataBuffer.Length);}public void Write(byte[] buffer, int offset, int count) //Client向PullAudioInputStream写入数据{_dataStream.Write(buffer, offset, count);}public override void Close(){if (_dataStream.DataAvailable){_waitForEmptyDataStream = new ManualResetEvent(false); //通过ManualResetEvent强制流的使用者必须调用close来手动关闭流_waitForEmptyDataStream.WaitOne();}_waitForEmptyDataStream.Close();_dataStream.Dispose();base.Close();}}

4 前端细节

前端是用Angular框架写的,这个Demo我们需要用到SignalR相关的库。使用npm install @aspnet/signalrnpm install @aspnet/signalr-protocol-msgpack安装这两个包,并导入到组件中:

此外在polyfills.ts文件中添加如下代码,否则可能会有浏览器兼容问题

在组件的初始化代码中,初始化Hub对象,并检查当前浏览器是否支持实时音频,目前主要是firefox和chrome支持浏览器流媒体获取。通过this.s2tHub.on('IncomingTranscript', (message) => { this.addMessage(message); });这句代码,给客户端注册了一个IncomingTranscript方法以供服务端调用

  constructor(private http: HttpClient) {this.s2tHub = new signalR.HubConnectionBuilder() .withUrl(this.apiurl) //apiurl是SignalR Hub的地址,我这里是'https://localhost:5001/s2thub'.withHubProtocol(new signalRmsgpack.MessagePackHubProtocol()) .configureLogging(signalR.LogLevel.Information).build(); // 创建Hub对象this.s2tHub.on('IncomingTranscript', (message) => {this.addMessage(message);});  // 在客户端注册IncomingTranscript接口}ngOnInit() {if (navigator.mediaDevices.getUserMedia) {  // 检测当前浏览器是否支持流媒体this.addMessage('This browser support getUserMedia');this.supportS2T = true;} else {this.addMessage('This browser does\'nt support getUserMedia');this.supportS2T = false;}}

点击"Start"后,启动Hub连接,开始向服务端发送数据。先是调用服务端的AudioStart接口通知服务端开始识别;然后调用ReceiveAudio接口先向服务端发送44字节的wav头数据,语音识别服务目前支持的音频类型是PCM和WAV,16000采样率,单声道,16位宽;最后在startStreaming中开始处理实时音频流。

  async startRecord() {if (!this.supportS2T) {return;}let connectSuccess = true;if (this.s2tHub.state !== signalR.HubConnectionState.Connected) {await this.s2tHub.start().catch(err => { this.addMessage(err); connectSuccess = false; } );}if (!connectSuccess){return;}this.s2tHub.send('AudioStart');this.s2tHub.send('ReceiveAudio', new Uint8Array(this.createStreamRiffHeader()));this.startStreaming();}private createStreamRiffHeader() {// create data bufferconst buffer = new ArrayBuffer(44);const view = new DataView(buffer);/* RIFF identifier */view.setUint8(0, 'R'.charCodeAt(0));view.setUint8(1, 'I'.charCodeAt(0));view.setUint8(2, 'F'.charCodeAt(0));view.setUint8(3, 'F'.charCodeAt(0));/* file length */view.setUint32(4, 0, true); // 因为不知道数据会有多长,先将其设为0/* RIFF type & Format */view.setUint8(8, 'W'.charCodeAt(0));view.setUint8(9, 'A'.charCodeAt(0));view.setUint8(10, 'V'.charCodeAt(0));view.setUint8(11, 'E'.charCodeAt(0));view.setUint8(12, 'f'.charCodeAt(0));view.setUint8(13, 'm'.charCodeAt(0));view.setUint8(14, 't'.charCodeAt(0));view.setUint8(15, ' '.charCodeAt(0));/* format chunk length */view.setUint32(16, 16, true); // 16位宽/* sample format (raw) */view.setUint16(20, 1, true);/* channel count */view.setUint16(22, 1, true); // 单通道/* sample rate */view.setUint32(24, 16000, true); // 16000采样率/* byte rate (sample rate * block align) */view.setUint32(28, 32000, true);/* block align (channel count * bytes per sample) */view.setUint16(32, 2, true);/* bits per sample */view.setUint16(34, 16, true);/* data chunk identifier */view.setUint8(36, 'd'.charCodeAt(0));view.setUint8(37, 'a'.charCodeAt(0));view.setUint8(38, 't'.charCodeAt(0));view.setUint8(39, 'a'.charCodeAt(0));/* data chunk length */view.setUint32(40, 0, true); // 因为不知道数据会有多长,先将其设为0return buffer;
}

在startStreaming方法中,首先调用window.navigator.mediaDevices.getUserMedia获取到麦克风输入流,然后创建AudioContext对象,用它创建多个音频处理节点,这些节点依次连接:

audioInput节点是音频输入节点,以音频流为输入;lowpassFilter节点作为一个滤波节点,对输入音频做低通滤波进行简单的降噪;jsScriptNode节点是主要的处理节点,可以为该节点添加事件处理,每当有数据进入该节点就进行处理和上传;最后是destination节点,它是Web Audio Context终结点,默认情况下会连接到本地的扬声器。

  private startStreaming() {window.navigator.mediaDevices.getUserMedia({audio: true}).then(mediaStream => {this.addMessage('get media stream successfully');this.audioStream = mediaStream;this.context = new AudioContext();this.audioInput = this.context.createMediaStreamSource(this.audioStream); // 源节点this.lowpassFilter = this.context.createBiquadFilter();this.lowpassFilter.type = 'lowpass';this.lowpassFilter.frequency.setValueAtTime(8000, this.context.currentTime); //滤波节点this.jsScriptNode = this.context.createScriptProcessor(4096, 1, 1); this.jsScriptNode.addEventListener('audioprocess', event => {this.processAudio(event);});  // 处理事件this.audioInput.connect(this.lowpassFilter);this.lowpassFilter.connect(this.jsScriptNode);this.jsScriptNode.connect(this.context.destination);}).catch(err => {this.addMessage('get media stream failed');});}

在jsScriptNode节点的audioprocess事件中,我们通过降采样、取单通道音频等处理获得正确格式的音频数据,再调用服务端的ReceiveAudio方法上传数据块

  private processAudio(audioProcessingEvent: any) {var inputBuffer = audioProcessingEvent.inputBuffer;// The output buffer contains the samples that will be modified and playedvar outputBuffer = audioProcessingEvent.outputBuffer;var isampleRate = inputBuffer.sampleRate;var osampleRate = 16000;var inputData = inputBuffer.getChannelData(0);var outputData = outputBuffer.getChannelData(0);var output = this.downsampleArray(isampleRate, osampleRate, inputData);for (var i = 0; i < outputBuffer.length; i++) {outputData[i] = inputData[i];}this.s2tHub.send('ReceiveAudio', new Uint8Array(output.buffer)).catch(err => {this.addMessage(err); });}private downsampleArray(irate: any, orate: any, input: any): Int16Array { // 降采样const ratio = irate / orate;const olength = Math.round(input.length / ratio);const output = new Int16Array(olength);var iidx = 0;var oidx = 0;for (var oidx = 0; oidx < output.length; oidx++) {const nextiidx = Math.round((oidx + 1) * ratio);var sum = 0;var cnt = 0;for (; iidx < nextiidx && iidx < input.length; iidx++) {sum += input[iidx];cnt++;}// saturate output between -1 and 1var newfval = Math.max(-1, Math.min(sum / cnt, 1));// multiply negative values by 2^15 and positive by 2^15 -1 (range of short)var newsval = newfval < 0 ? newfval * 0x8000 : newfval * 0x7FFF;output[oidx] = Math.round(newsval);}return output;}

最后,定义stopRecord方法,断开AudioContext连接和Hub连接:

  async stopRecord() {this.jsScriptNode.disconnect(this.context.destination);this.lowpassFilter.disconnect(this.jsScriptNode);this.audioInput.disconnect(this.lowpassFilter);this.s2tHub.stop();}

5 扩展

  • 自定义语音模型
    可以上传训练数据进行自定义模型的训练,得到的自定义语音服务可以适应更加特定的业务场景。 不过自定义模型是需要开通标准收费服务的
  • 容器部署
    微软目前也支持服务的容器部署,通过服务的本地部署,可以提高信息传输速度,并且减小私有信息安全性的顾虑

ASP.NET Core环境Web Audio API+SingalR+微软语音服务实现web实时语音识别相关推荐

  1. Web Audio API之手把手教你用web api处理声音信号:可视化音乐demo

    1.Web Audio API 介绍 Web Audio API 提供了在Web上控制音频的一个非常有效通用的系统 ,这些通用系统通俗的讲就是我们可以利用Web Audio API提供的各种方法操作各 ...

  2. 在Linux(Ubuntu)下搭建ASP.NET Core环境并运行 继续跨平台

    最新教程:http://www.cnblogs.com/linezero/p/aspnetcoreubuntu.html 无需安装mono,在Linux(Ubuntu)下搭建ASP.NET Core环 ...

  3. 利用HTML5 Web Audio API给网页JS交互增加声音

    转自张鑫旭老师博客 原文地址 一.庞然的HTML5 Web Audio API 首先务必要弄清这一点,本文这里所说的HTML5 Web Audio API和HTML5 元素完全不是一个东西,其体量也完 ...

  4. 如何使用Web Audio API听到“ Yanny”和“ Laurel”的声音

    by _haochuan 通过_haochuan 如何使用Web Audio API听到" Yanny"和" Laurel"的声音 (How you can h ...

  5. ASP.NET CORE 1.0 MVC API 文档用 SWASHBUCKLE SWAGGER实现

    from:https://damienbod.com/2015/12/13/asp-net-5-mvc-6-api-documentation-using-swagger/ 代码生成工具: https ...

  6. ASP.NET Core应用针对静态文件请求的处理[1]: 以Web的形式发布静态文件

    虽然ASP.NET Core是一款"动态"的Web服务端框架,但是在很多情况下都需要处理针对静态文件的请求,最为常见的就是这对JavaScript脚本文件.CSS样式文件和图片文件 ...

  7. Web Audio API

    Web audio concepts and usage 1.创建audio context 2.在context中创建source,例如<audio>, OscillatorNode, ...

  8. Web Audio API 入门1

    将吉他和效果器(effect pedal 比如失真效果器)链接,然后将效果器和放大器(amplifier)链接,最后将放大器和音响(speaker)连接,既:吉他-效果器–放大器-音响 Web Aud ...

  9. Web Audio API与WebSocket播放实时音频

    WebSocket客户端与Web Audio API示例 <!DOCTYPE html> <html><head><meta charset="ut ...

最新文章

  1. elegance suites bangkok info
  2. RAC环境下的备份与恢复(二)
  3. websocket vs keep-live
  4. 关于华硕主板“USB Devices Over Current Status Detected!”
  5. javascript tabIndex属性
  6. lsm tree java_BasicTreeUI
  7. Could not create a sandbox extension for /
  8. AEscripts Fog for Mac - 模拟真实三维体薄雾AE/PR插件
  9. python installer 在 mac 运行_python – 如何在Mac OS X 10.7中的virtualenv中安装PyAudio
  10. directdraw显示yuv视频,出现屏保时,yuv显示不出来,表面丢失
  11. ubuntu google earth 乱码 自动关闭
  12. AD之前的电压跟随器可以不用吗?
  13. 软件工程中英对照术语表
  14. 上官婉儿墓志 - 还原历史真相
  15. 微信小程序排坑:请选择含app.json / project.config.json的目录
  16. selenium模拟登录某宝
  17. 360怎样修改wifi服务器,360路由器怎么设置无线网络
  18. 5个不为人知的黑科技手机APP,绝对让你大开眼见!
  19. MacPorts 初装后提示 command not found: port 解决方案
  20. metasploit meterpreter介绍

热门文章

  1. 求解n阶方阵的行列式
  2. 卡罗林斯卡医学院计算机方向,卡罗林斯卡医学院:全球卫生专业受学生欢迎
  3. python在循环中创建dataframe(如df1、df2……)
  4. 使用Spring获取JavaBean的属性值匹配短信模板
  5. Linux ALSA声卡驱动之五:Machine 以及ALSA声卡的注册
  6. SpringBoot实现每天给对象发送情话
  7. spring-boog-测试打桩-Mockito
  8. 2011-07 《信息资源管理 02378》真卷解析,逐题解析+背诵技巧
  9. Linux 下播放音乐和视频
  10. 社交APP的核心功能都有哪些