个性化阅读
专注于IT技术分析

MIDI教程:创建由MIDI硬件控制的基于浏览器的音频应用程序

本文概述

尽管Web Audio API越来越流行, 尤其是在HTML5游戏开发人员中, 但Web MIDI API在前端开发人员中仍然鲜为人知。其中很大一部分可能与当前缺乏支持和可访问的文档有关;前提是你为Web MIDI API启用了特殊标志, 因此它仅受Google Chrome浏览器支持。浏览器制造商目前很少强调此API, 因为它计划成为ES7标准的一部分。

MIDI(乐器数字接口的缩写)是由几家音乐行业的代表在80年代初设计的, 它是电子音乐设备的标准通信协议。从那时起, 尽管开发了其他协议, 例如OSC。三十年后, MIDI仍然是音频硬件制造商的实际通信协议。你将很难在自己的录音室中找到一家没有至少一个MIDI设备的现代音乐制作人。

随着Web Audio API的快速开发和采用, 我们现在可以开始构建基于浏览器的应用程序, 从而弥合云与物理世界之间的鸿沟。 Web MIDI API不仅使我们能够构建合成器和音频效果, 而且甚至可以开始构建与当前基于Flash的同类功能和性能相似的基于浏览器的DAW(数字音频工作站)(例如, 查看Audiotool) )。

在本MIDI教程中, 我将指导你学习Web MIDI API的基础知识, 并且我们将构建一个简单的monosynth, 你将可以使用自己喜欢的MIDI设备进行演奏。完整的源代码在这里, 你可以直接测试现场演示。如果你没有MIDI设备, 你仍然可以通过检查GitHub存储库的”键盘”分支来遵循本教程, 该分支为你的计算机键盘提供了基本支持, 因此你可以弹奏音符和更改八度。这也是作为现场演示可用的版本。但是, 由于计算机硬件的限制, 每当你使用计算机键盘来控制合成器时, 速度和失谐都将被禁用。请参考GitHub上的自述文件以了解键/注释映射。

srcmini的Midi教程

Midi教程先决条件

本MIDI教程需要具备以下条件:

  • 启用了#enable-web-midi标志的Google Chrome(版本38或更高版本)
  • (可选)连接到计算机的MIDI设备, 可以触发音符

我们还将使用Angular.js为我们的应用程序带来一些结构;因此, 该框架的基础知识是前提。

入门

通过将MIDI应用程序分为3个模块, 我们将从头开始对其进行模块化:

  • WebMIDI:处理连接到计算机的各种MIDI设备
  • WebAudio:为我们的合成器提供音频源
  • WebSynth:将Web界面连接到音频引擎

一个App模块将处理与Web用户界面的用户交互。我们的应用程序结构可能看起来像这样:

|- app
|-- js
|--- midi.js
|--- audio.js
|--- synth.js
|--- app.js
|- index.html

你还应该安装以下库来帮助你构建应用程序:Angular.js, Bootstrap和jQuery。安装这些组件的最简单方法可能是通过Bower。

WebMIDI模块:与现实世界连接

通过将MIDI设备连接到我们的应用程序, 开始弄清楚如何使用MIDI。为此, 我们将创建一个返回单个方法的简单工厂。要通过Web MIDI API连接到我们的MIDI设备, 我们需要调用navigator.requestMIDIAc​​cess方法:

angular
    .module('WebMIDI', [])
    .factory('Devices', ['$window', function($window) {
        function _connect() {
            if($window.navigator && 'function' === typeof $window.navigator.requestMIDIAccess) {
                $window.navigator.requestMIDIAccess();
            } else {
                throw 'No Web MIDI support';
            }
        }

        return {
            connect: _connect
        };
    }]);

就是这样!

requestMIDIAc​​cess方法返回承诺, 因此我们可以直接将其返回并在应用程序的控制器中处理承诺的结果:

angular
    .module('DemoApp', ['WebMIDI'])
    .controller('AppCtrl', ['$scope', 'Devices', function($scope, devices) {
        $scope.devices = [];

        devices
            .connect()
            .then(function(access) {
                if('function' === typeof access.inputs) {
                    // deprecated
                    $scope.devices = access.inputs();
                    console.error('Update your Chrome version!');
                } else {
                    if(access.inputs && access.inputs.size > 0) {
                        var inputs = access.inputs.values(), input = null;

                        // iterate through the devices
                        for (input = inputs.next(); input && !input.done; input = inputs.next()) {
                            $scope.devices.push(input.value);
                        }
                    } else {
                        console.error('No devices detected!');
                    }

                }
            })
            .catch(function(e) {
                console.error(e);
            });
    }]);

如前所述, requestMIDIAc​​cess方法返回一个promise, 将一个对象传递给then方法, 该对象具有两个属性:输入和输出。

在早期版本的Chrome中, 这两个属性是允许你直接检索输入和输出设备数组的方法。但是, 在最新更新中, 这些属性现在是对象。这有很大的不同, 因为我们现在需要在输入或输出对象上调用values方法来检索相应的设备列表。此方法充当生成器函数, 并返回迭代器。同样, 该API也打算成为ES7的一部分;因此, 实现类似生成器的行为是有意义的, 即使它不像原始实现那样简单明了。

最后, 我们可以通过迭代器对象的size属性检索设备数量。如果至少有一个设备, 我们只需调用迭代器对象的next方法, 然后将每个设备推送到$ scope定义的数组上, 就可以对结果进行迭代。在前端, 我们可以实现一个简单的选择框, 该框将列出所有可用的输入设备, 并让我们选择要将哪个设备用作控制Web合成器的活动设备:

<select ng-model="activeDevice" class="form-control" ng-options="device.manufacturer + ' ' + device.name for device in devices">
    <option value="" disabled>Choose a MIDI device...</option>
</select>

我们将此选择框绑定到一个名为activeDevice的$ scope变量, 稍后将使用该变量将该活动设备连接到合成器。

将此活动设备连接到合成器

WebAudio模块:产生噪音

WebAudio API使我们不仅可以播放声音文件, 还可以通过重新创建合成器的基本组件(如振荡器, 滤波器和增益节点等)来生成声音。

创建一个振荡器

振荡器的作用是输出波形。有多种类型的波形, 其中WebAudio API支持四种波形:正弦波, 方波, 三角波和锯齿波。据说波形以一定的频率”振荡”, 但是如果需要, 也可以定义自己的自定义波表。人们可以听到一定范围的频率-它们被称为声音。另外, 当它们在低频振荡时, 振荡器还可以帮助我们构建LFO(“低频振荡器”), 以便我们可以调制声音(但这不在本教程的讨论范围之内)。

创建声音的第一件事是实例化一个新的AudioContext:

function _createContext() {
    self.ctx = new $window.AudioContext();
}

从那里, 我们可以实例化WebAudio API提供的任何组件。由于我们可能会为每个组件创建多个实例, 因此创建服务以能够创建所需的组件的新的唯一实例是有意义的。首先创建服务以生成新的振荡器:

angular
    .module('WebAudio', [])
    .service('OSC', function() {
        var self;

        function Oscillator(ctx) {
            self = this;
            self.osc = ctx.createOscillator();

            return self;
        }
    });

现在, 我们可以随意实例化新的振荡器, 将我们先前创建的AudioContext实例作为参数传递。为了使事情变得更简单, 我们将添加一些包装方法(仅语法糖), 并返回Oscillator函数:

Oscillator.prototype.setOscType = function(type) {
    if(type) {
        self.osc.type = type
    }
}

Oscillator.prototype.setFrequency = function(freq, time) {
    self.osc.frequency.setTargetAtTime(freq, 0, time);
};

Oscillator.prototype.start = function(pos) {
    self.osc.start(pos);
}

Oscillator.prototype.stop = function(pos) {
    self.osc.stop(pos);
}

Oscillator.prototype.connect = function(i) {
    self.osc.connect(i);
}

Oscillator.prototype.cancel = function() {
    self.osc.frequency.cancelScheduledValues(0);
}

return Oscillator;

创建多通滤波器和音量控件

我们还需要两个组件来完善我们的基本音频引擎:一个多通滤波器, 可以使声音有些变形, 增益节点可以控制声音的音量并打开和关闭音量。为此, 我们可以采用与振荡器相同的方式进行操作:创建返回带有某些包装方法的函数的服务。我们需要做的就是提供AudioContext实例并调用适当的方法。

我们通过调用AudioContext实例的createBiquadFilter方法来创建过滤器:

ctx.createBiquadFilter();

同样, 对于增益节点, 我们调用createGain方法:

ctx.createGain();

WebSynth模块:接线

现在, 我们几乎已经准备好构建合成器界面, 并将MIDI设备连接到我们的音频源。首先, 我们需要将音频引擎连接在一起, 并准备好接收MIDI音符。要连接音频引擎, 我们只需创建所需组件的新实例, 然后使用可用于每个组件实例的connect方法将它们”连接”在一起。 connect方法采用一个参数, 它只是要将当前实例连接到的组件。由于连接方法可以将一个节点连接到多个调制器, 因此可以编排更复杂的组件链(从而可以实现交叉渐变等)。

self.osc1 = new Oscillator(self.ctx);
self.osc1.setOscType('sine');
self.amp = new Amp(self.ctx);

self.osc1.connect(self.amp.gain);

self.amp.connect(self.ctx.destination);
self.amp.setVolume(0.0, 0); //mute the sound
    self.filter1.disconnect();
    self.amp.disconnect();
    self.amp.connect(self.ctx.destination);
}

我们只是建立了音频引擎的内部布线。你可以稍作练习, 尝试不同的接线组合, 但请记住调低音量以避免耳聋。现在, 我们可以将MIDI接口连接到我们的应用程序, 并将MIDI消息发送到音频引擎。我们将在设备选择框中设置一个观察程序, 以将其虚拟地”插入”到我们的合成器中。然后, 我们将收听来自设备的MIDI消息, 并将信息传递给音频引擎:

// in the app's controller
$scope.$watch('activeDevice', DSP.plug);

// in the synth module
function _onmidimessage(e) {
    /**
    * e.data is an array
    * e.data[0] = on (144) / off (128) / detune (224)
    * e.data[1] = midi note
    * e.data[2] = velocity || detune
    */
    switch(e.data[0]) {
        case 144:
            Engine.noteOn(e.data[1], e.data[2]);
            break;
        case 128:
            Engine.noteOff(e.data[1]);
            break;
    }

}

function _plug(device) {
    self.device = device;
    self.device.onmidimessage = _onmidimessage;
}

在这里, 我们正在监听设备的MIDI事件, 分析MidiEvent对象的数据, 并将其传递给适当的方法;基于事件代码(noteOn为144, noteOff为128)为noteOn或noteOff。现在, 我们可以在音频模块的相应方法中添加逻辑以实际生成声音:

function _noteOn(note, velocity) {
    self.activeNotes.push(note);

    self.osc1.cancel();
    self.currentFreq = _mtof(note);
    self.osc1.setFrequency(self.currentFreq, self.settings.portamento);

    self.amp.cancel();

    self.amp.setVolume(1.0, self.settings.attack);
}

function _noteOff(note) {
    var position = self.activeNotes.indexOf(note);
    if (position !== -1) {
        self.activeNotes.splice(position, 1);
    }

    if (self.activeNotes.length === 0) {
        // shut off the envelope
        self.amp.cancel();
        self.currentFreq = null;
        self.amp.setVolume(0.0, self.settings.release);
    } else {
        // in case another note is pressed, we set that one as the new active note
        self.osc1.cancel();
        self.currentFreq = _mtof(self.activeNotes[self.activeNotes.length - 1]);
        self.osc1.setFrequency(self.currentFreq, self.settings.portamento);
    }
}

这里发生了一些事情。在noteOn方法中, 我们首先将当前注释推到注释数组中。即使我们正在建立一个单音符合成器(意味着我们一次只能演奏一个音符), 我们仍然可以同时在键盘上有多个手指。因此, 我们需要将所有这些音符排入队列, 以便在释放一个音符时播放下一个音符。然后, 我们需要停止振荡器以分配新的频率, 然后将其从MIDI音符(从0到127的标度)转换为实际的频率值, 并需要一点数学运算:

function _mtof(note) {
    return 440 * Math.pow(2, (note - 69) / 12);
}

在noteOff方法中, 我们首先从活动笔记数组中找到该笔记并将其删除。然后, 如果它是阵列中的唯一音符, 我们只需关闭音量即可。

setVolume方法的第二个参数是过渡时间, 这意味着增益达到新的音量值需要多长时间。用音乐术语来说, 如果音符打开, 则相当于开始时间, 如果音符关闭, 则等于释放时间。

WebAnalyser模块:可视化我们的声音

我们可以添加到合成器中的另一个有趣功能是分析器节点, 该节点使我们能够使用画布显示声音的波形以进行渲染。创建分析器节点比其他AudioContext对象要复杂一些, 因为它还需要创建一个scriptProcessor节点来实际执行分析。我们首先选择DOM上的canvas元素:

function Analyser(canvas) {
    self = this;

    self.canvas = angular.element(canvas) || null;
    self.view = self.canvas[0].getContext('2d') || null;
    self.javascriptNode = null;
    self.analyser = null;

    return self;
}

然后, 我们添加一个connect方法, 在该方法中, 我们将创建分析器和脚本处理器:

Analyser.prototype.connect = function(ctx, output) {
    // setup a javascript node
    self.javascriptNode = ctx.createScriptProcessor(2048, 1, 1);
    // connect to destination, else it isn't called
    self.javascriptNode.connect(ctx.destination);

    // setup an analyzer
    self.analyser = ctx.createAnalyser();
    self.analyser.smoothingTimeConstant = 0.3;
    self.analyser.fftSize = 512;

    // connect the output to the destination for sound
    output.connect(ctx.destination);
    // connect the output to the analyser for processing
    output.connect(self.analyser);

    self.analyser.connect(self.javascriptNode);

    // define the colors for the graph
    var gradient = self.view.createLinearGradient(0, 0, 0, 200);
    gradient.addColorStop(1, '#000000');
    gradient.addColorStop(0.75, '#ff0000');
    gradient.addColorStop(0.25, '#ffff00');
    gradient.addColorStop(0, '#ffffff');

    // when the audio process event is fired on the script processor
    // we get the frequency data into an array
    // and pass it to the drawSpectrum method to render it in the canvas
    self.javascriptNode.onaudioprocess = function() {
        // get the average for the first channel
        var array =  new Uint8Array(self.analyser.frequencyBinCount);
        self.analyser.getByteFrequencyData(array);

        // clear the current state
        self.view.clearRect(0, 0, 1000, 325);

        // set the fill style
        self.view.fillStyle = gradient;
        drawSpectrum(array);
    }
};

首先, 我们创建一个scriptProcessor对象, 并将其连接到目标。然后, 我们创建分析器本身, 然后将其与振荡器或滤波器的音频输出一起输入。注意我们仍然需要将音频输出连接到目的地, 以便我们听到它!我们还需要定义图形的渐变颜色-这是通过调用canvas元素的createLinearGradient方法来完成的。

最后, scriptProcessor将间隔触发一次” audioprocess”事件;当触发此事件时, 我们计算分析器捕获的平均频率, 清除画布, 然后通过调用drawSpectrum方法重绘新的频率图:

function drawSpectrum(array) {
    for (var i = 0; i < (array.length); i++) {
        var v = array[i], h = self.canvas.height();

        self.view.fillRect(i * 2, h - (v - (h / 4)), 1, v + (h / 4));
    }
}

最后但并非最不重要的一点是, 我们将需要稍微修改音频引擎的接线以适应此新组件:

// in the _connectFilter() method
if(self.analyser) {
    self.analyser.connect(self.ctx, self.filter1);
} else {
    self.filter1.connect(self.ctx.destination);
}

// in the _disconnectFilter() method
if(self.analyser) {
    self.analyser.connect(self.ctx, self.amp);
} else {
    self.amp.connect(self.ctx.destination);
}

现在, 我们有了一个不错的可视化工具, 使我们可以实时显示合成器的波形!这涉及一些设置工作, 但非常有趣且很有见识, 尤其是在使用过滤器时。

建立在我们的合成器上:增加力度和失谐

至此, 在MIDI教程中, 我们有了一个很酷的合成器-但它以相同的音量播放每个音符。这是因为我们没有正确地处理速度数据, 而是简单地将音量设置为固定值1.0。让我们从修复此问题开始, 然后我们将了解如何启用最常见的MIDI键盘上的失谐轮。

启用速度

如果你不熟悉它, 则”力度”与你按下键盘琴键的力度有关。基于此值, 所产生的声音看起来更柔和或更响亮。

在我们的MIDI教程合成器中, 我们可以通过简单地玩弄增益节点的音量来模拟这种行为。为此, 我们首先需要做一些数学运算以将MIDI数据转换为0.0到1.0之间的浮点值, 以传递到增益节点:

function _vtov (velocity) {
    return (velocity / 127).toFixed(2);
}

MIDI设备的速度范围是从0到127, 因此我们只需将该值除以127, 然后返回带有两位小数的浮点值。然后, 我们可以更新_noteOn方法以将计算出的值传递给增益节点:

self.amp.setVolume(_vtov(velocity), self.settings.attack);

就是这样!现在, 当我们演奏合成器时, 我们会注意到音量根据我们敲击键盘琴键的力度而有所不同。

在MIDI键盘上启用Detune Wheel

大多数MIDI键盘带有一个失谐滚轮。滚轮可让你稍微改变当前正在播放的音符的频率, 从而产生一种有趣的效果, 称为”失谐”。当你学习如何使用MIDI时, 这相当容易实现, 因为失谐轮还使用其自己的事件代码(224)触发MidiMessage事件, 我们可以通过重新计算频率值并更新振荡器来监听并采取行动。

首先, 我们需要在合成器中捕捉事件。为此, 我们在_onmidimessage回调中创建的switch语句中添加了一个额外的大小写:

case 224:
    // the detune value is the third argument of the MidiEvent.data array
    Engine.detune(e.data[2]);
    break;

然后, 我们在音频引擎上定义detune方法:

function _detune(d) {
    if(self.currentFreq) {
        //64 = no detune
        if(64 === d) {
            self.osc1.setFrequency(self.currentFreq, self.settings.portamento);
            self.detuneAmount = 0;
        } else {
            var detuneFreq = Math.pow(2, 1 / 12) * (d - 64);
            self.osc1.setFrequency(self.currentFreq + detuneFreq, self.settings.portamento);
            self.detuneAmount = detuneFreq;
        }
    }
}

默认失谐值为64, 这意味着未应用失谐, 因此在这种情况下, 我们仅将当前频率传递给振荡器。

最后, 我们还需要更新_noteOff方法, 以考虑在另一个音符排队时的失谐:

self.osc1.setFrequency(self.currentFreq + self.detuneAmount, self.settings.portamento);

创建界面

到目前为止, 我们只创建了一个选择框以能够选择MIDI设备和波形可视化器, 但是我们无法通过与网页交互直接修改声音。让我们使用常见的表单元素创建一个非常简单的界面, 并将其绑定到我们的音频引擎。

为界面创建布局

我们将创建各种形式的元素来控制合成器的声音:

  • 选择振荡器类型的无线电组
  • 启用/禁用过滤器的复选框
  • 选择过滤器类型的单选组
  • 两个范围来控制滤波器的频率和谐振
  • 两个范围控制增益节点的起跳和释放

为我们的界面创建一个HTML文档, 我们应该以如下形式结束:

<div class="synth container" ng-controller="WebSynthCtrl">
    <h1>webaudio synth</h1>
    <div class="form-group">
        <select ng-model="activeDevice" class="form-control" ng-options="device.manufacturer + ' ' + device.name for device in devices">
            <option value="" disabled>Choose a MIDI device...</option>
        </select>
    </div>
    <div class="col-lg-6 col-md-6 col-sm-6">
        <h2>Oscillator</h2>
        <div class="form-group">
            <h3>Oscillator Type</h3>
            <label ng-repeat="t in oscTypes">
                <input type="radio" name="oscType" ng-model="synth.oscType" value="{{t}}" ng-checked="'{{t}}' === synth.oscType" />
                {{t}} 
            </label>
        </div>
        <h2>Filter</h2>
        <div class="form-group">
            <label>
                <input type="checkbox" ng-model="synth.filterOn" />
                enable filter
            </label>
        </div>
        <div class="form-group">
            <h3>Filter Type</h3>
            <label ng-repeat="t in filterTypes">
                <input type="radio" name="filterType" ng-model="synth.filterType" value="{{t}}" ng-disabled="!synth.filterOn" ng-checked="synth.filterOn && '{{t}}' === synth.filterType" />
                {{t}} 
            </label>
        </div>
        <div class="form-group">
            <!-- frequency -->
            <label>filter frequency:</label>
            <input type="range" class="form-control" min="50" max="10000" ng-model="synth.filterFreq" ng-disabled="!synth.filterOn" />
        </div>
        <div class="form-group">
            <!-- resonance -->
            <label>filter resonance:</label>
            <input type="range" class="form-control" min="0" max="150" ng-model="synth.filterRes" ng-disabled="!synth.filterOn" />
        </div>
    </div>
    <div class="col-lg-6 col-md-6 col-sm-6">
        <div class="panel panel-default">
            <div class="panel-heading">Analyser</div>
            <div class="panel-body">
                <!-- frequency analyser -->
                <canvas id="analyser"></canvas>
            </div>
        </div>
        <div class="form-group">
            <!-- attack -->
            <label>attack:</label>
            <input type="range" class="form-control" min="50" max="2500" ng-model="synth.attack" />
        </div>
        <div class="form-group">
            <!-- release -->
            <label>release:</label>
            <input type="range" class="form-control" min="50" max="1000" ng-model="synth.release" />
        </div>
    </div>
</div>

在基本的MIDI教程中不会介绍如何装饰用户界面使其看起来花哨的内容。相反, 我们可以将其保存为练习, 以供日后完善用户界面使用, 也许看起来像这样:

优美的Midi用户界面

将接口绑定到音频引擎

我们应该定义一些方法将这些控件绑定到我们的音频引擎。

控制振荡器

对于振荡器, 我们只需要一种允许设置振荡器类型的方法即可:

Oscillator.prototype.setOscType = function(type) {
    if(type) {
        self.osc.type = type;
    }
}

控制过滤器

对于滤波器, 我们需要三个控件:一个用于滤波器类型, 一个用于频率, 另一个用于谐振。我们还可以将_connectFilter和_disconnectFilter方法连接到复选框的值。

Filter.prototype.setFilterType = function(type) {
    if(type) {
        self.filter.type = type;
    }
}
Filter.prototype.setFilterFrequency = function(freq) {
    if(freq) {
        self.filter.frequency.value = freq;
    }
}
Filter.prototype.setFilterResonance = function(res) {
    if(res) {
        self.filter.Q.value = res;
    }
}

控制起音和共鸣

为了稍微塑造声音, 我们可以更改增益节点的起音和释放参数。为此, 我们需要两种方法:

function _setAttack(a) {
    if(a) {
        self.settings.attack = a / 1000;
    }
}

function _setRelease(r) {
    if(r) {
        self.settings.release = r / 1000;
    }
}

设置观察者

最后, 在我们应用的控制器中, 我们只需要设置几个观察者并将它们绑定到我们刚创建的各种方法即可:

$scope.$watch('synth.oscType', DSP.setOscType);
$scope.$watch('synth.filterOn', DSP.enableFilter);
$scope.$watch('synth.filterType', DSP.setFilterType);
$scope.$watch('synth.filterFreq', DSP.setFilterFrequency);
$scope.$watch('synth.filterRes', DSP.setFilterResonance);
$scope.$watch('synth.attack', DSP.setAttack);
$scope.$watch('synth.release', DSP.setRelease);

总结

这个MIDI教程涵盖了很多概念。大多数情况下, 我们发现了如何使用WebMIDI API, 除了W3C的官方规范外, 该文件还没有文档记录。 Google Chrome的实现非常简单, 尽管切换到输入和输出设备的迭代器对象需要使用旧的实现对旧代码进行一些重构。

对于WebAudio API, 这是一个非常丰富的API, 在本教程中我们仅介绍了其一些功能。与WebMIDI API不同, WebAudio API的文档非常详尽, 尤其是在Mozilla开发人员网络上。 Mozilla开发人员网络包含大量代码示例以及每个组件的各种参数和事件的详细列表, 这将帮助你实现自己的基于浏览器的自定义音频应用程序。

随着这两个API的不断增长, 它将为JavaScript开发人员带来一些非常有趣的可能性;使我们能够开发功能齐全的基于浏览器的DAW, 该DAW将能够与Flash同类产品竞争。对于台式机开发人员, 你还可以使用诸如node-webkit之类的工具开始创建自己的跨平台应用程序。希望这将为发烧友产生新一代的音乐工具, 通过弥合物理世界和云之间的鸿沟, 为用户提供支持。

赞(0)
未经允许不得转载:srcmini » MIDI教程:创建由MIDI硬件控制的基于浏览器的音频应用程序

评论 抢沙发

评论前必须登录!