parent
cc15af549b
commit
4f46a164ba
File diff suppressed because one or more lines are too long
@ -0,0 +1,97 @@
|
||||
(function(window){
|
||||
|
||||
var WORKER_PATH = 'recorderWorker.js';
|
||||
|
||||
var Recorder = function(source, cfg){
|
||||
var config = cfg || {};
|
||||
var bufferLen = config.bufferLen || 4096;
|
||||
this.context = source.context;
|
||||
this.node = (this.context.createScriptProcessor ||
|
||||
this.context.createJavaScriptNode).call(this.context,
|
||||
bufferLen, 2, 2);
|
||||
var worker = new Worker(config.workerPath || WORKER_PATH);
|
||||
worker.postMessage({
|
||||
command: 'init',
|
||||
config: {
|
||||
sampleRate: this.context.sampleRate
|
||||
}
|
||||
});
|
||||
var recording = false,
|
||||
currCallback;
|
||||
|
||||
var self = this;
|
||||
this.node.onaudioprocess = function(e){
|
||||
if (!recording) return;
|
||||
self.ondata && self.ondata(e.inputBuffer.getChannelData(0));
|
||||
worker.postMessage({
|
||||
command: 'record',
|
||||
buffer: [
|
||||
e.inputBuffer.getChannelData(0),
|
||||
e.inputBuffer.getChannelData(1)
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
this.configure = function(cfg){
|
||||
for (var prop in cfg){
|
||||
if (cfg.hasOwnProperty(prop)){
|
||||
config[prop] = cfg[prop];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.record = function(){
|
||||
recording = true;
|
||||
}
|
||||
|
||||
this.stop = function(){
|
||||
recording = false;
|
||||
}
|
||||
|
||||
this.clear = function(){
|
||||
worker.postMessage({ command: 'clear' });
|
||||
}
|
||||
|
||||
this.getBuffer = function(cb) {
|
||||
currCallback = cb || config.callback;
|
||||
worker.postMessage({ command: 'getBuffer' })
|
||||
}
|
||||
|
||||
this.exportWAV = function(cb, type){
|
||||
currCallback = cb || config.callback;
|
||||
type = type || config.type || 'audio/wav';
|
||||
if (!currCallback) throw new Error('Callback not set');
|
||||
worker.postMessage({
|
||||
command: 'exportWAV',
|
||||
type: type
|
||||
});
|
||||
}
|
||||
|
||||
this.shutdown = function(){
|
||||
worker.terminate();
|
||||
source.disconnect();
|
||||
this.node.disconnect();
|
||||
};
|
||||
|
||||
worker.onmessage = function(e){
|
||||
var blob = e.data;
|
||||
currCallback(blob);
|
||||
}
|
||||
|
||||
source.connect(this.node);
|
||||
this.node.connect(this.context.destination); //this should not be necessary
|
||||
};
|
||||
|
||||
Recorder.forceDownload = function(blob, filename){
|
||||
var url = (window.URL || window.webkitURL).createObjectURL(blob);
|
||||
var link = window.document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename || 'output.wav';
|
||||
var click = document.createEvent("Event");
|
||||
click.initEvent("click", true, true);
|
||||
link.dispatchEvent(click);
|
||||
}
|
||||
|
||||
window.Recorder = Recorder;
|
||||
|
||||
})(window);
|
@ -0,0 +1,131 @@
|
||||
var recLength = 0,
|
||||
recBuffersL = [],
|
||||
recBuffersR = [],
|
||||
sampleRate;
|
||||
|
||||
this.onmessage = function(e){
|
||||
switch(e.data.command){
|
||||
case 'init':
|
||||
init(e.data.config);
|
||||
break;
|
||||
case 'record':
|
||||
record(e.data.buffer);
|
||||
break;
|
||||
case 'exportWAV':
|
||||
exportWAV(e.data.type);
|
||||
break;
|
||||
case 'getBuffer':
|
||||
getBuffer();
|
||||
break;
|
||||
case 'clear':
|
||||
clear();
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
function init(config){
|
||||
sampleRate = config.sampleRate;
|
||||
}
|
||||
|
||||
function record(inputBuffer){
|
||||
recBuffersL.push(inputBuffer[0]);
|
||||
recBuffersR.push(inputBuffer[1]);
|
||||
recLength += inputBuffer[0].length;
|
||||
}
|
||||
|
||||
function exportWAV(type){
|
||||
var bufferL = mergeBuffers(recBuffersL, recLength);
|
||||
var bufferR = mergeBuffers(recBuffersR, recLength);
|
||||
var interleaved = interleave(bufferL, bufferR);
|
||||
var dataview = encodeWAV(interleaved);
|
||||
var audioBlob = new Blob([dataview], { type: type });
|
||||
|
||||
this.postMessage(audioBlob);
|
||||
}
|
||||
|
||||
function getBuffer() {
|
||||
var buffers = [];
|
||||
buffers.push( mergeBuffers(recBuffersL, recLength) );
|
||||
buffers.push( mergeBuffers(recBuffersR, recLength) );
|
||||
this.postMessage(buffers);
|
||||
}
|
||||
|
||||
function clear(){
|
||||
recLength = 0;
|
||||
recBuffersL = [];
|
||||
recBuffersR = [];
|
||||
}
|
||||
|
||||
function mergeBuffers(recBuffers, recLength){
|
||||
var result = new Float32Array(recLength);
|
||||
var offset = 0;
|
||||
for (var i = 0; i < recBuffers.length; i++){
|
||||
result.set(recBuffers[i], offset);
|
||||
offset += recBuffers[i].length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function interleave(inputL, inputR){
|
||||
var length = inputL.length + inputR.length;
|
||||
var result = new Float32Array(length);
|
||||
|
||||
var index = 0,
|
||||
inputIndex = 0;
|
||||
|
||||
while (index < length){
|
||||
result[index++] = inputL[inputIndex];
|
||||
result[index++] = inputR[inputIndex];
|
||||
inputIndex++;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function floatTo16BitPCM(output, offset, input){
|
||||
for (var i = 0; i < input.length; i++, offset+=2){
|
||||
var s = Math.max(-1, Math.min(1, input[i]));
|
||||
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
|
||||
}
|
||||
}
|
||||
|
||||
function writeString(view, offset, string){
|
||||
for (var i = 0; i < string.length; i++){
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
function encodeWAV(samples){
|
||||
var buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||
var view = new DataView(buffer);
|
||||
|
||||
/* RIFF identifier */
|
||||
writeString(view, 0, 'RIFF');
|
||||
/* RIFF chunk length */
|
||||
view.setUint32(4, 36 + samples.length * 2, true);
|
||||
/* RIFF type */
|
||||
writeString(view, 8, 'WAVE');
|
||||
/* format chunk identifier */
|
||||
writeString(view, 12, 'fmt ');
|
||||
/* format chunk length */
|
||||
view.setUint32(16, 16, true);
|
||||
/* sample format (raw) */
|
||||
view.setUint16(20, 1, true);
|
||||
/* channel count */
|
||||
view.setUint16(22, 2, true);
|
||||
/* sample rate */
|
||||
view.setUint32(24, sampleRate, true);
|
||||
/* byte rate (sample rate * block align) */
|
||||
view.setUint32(28, sampleRate * 4, true);
|
||||
/* block align (channel count * bytes per sample) */
|
||||
view.setUint16(32, 4, true);
|
||||
/* bits per sample */
|
||||
view.setUint16(34, 16, true);
|
||||
/* data chunk identifier */
|
||||
writeString(view, 36, 'data');
|
||||
/* data chunk length */
|
||||
view.setUint32(40, samples.length * 2, true);
|
||||
|
||||
floatTo16BitPCM(view, 44, samples);
|
||||
|
||||
return view;
|
||||
}
|
@ -0,0 +1,199 @@
|
||||
(function(window) {
|
||||
// internal: same as jQuery.extend(true, args...)
|
||||
var extend = function() {
|
||||
var target = arguments[0],
|
||||
sources = [].slice.call(arguments, 1);
|
||||
for (var i = 0; i < sources.length; ++i) {
|
||||
var src = sources[i];
|
||||
for (key in src) {
|
||||
var val = src[key];
|
||||
target[key] = typeof val === "object"
|
||||
? extend(typeof target[key] === "object" ? target[key] : {}, val)
|
||||
: val;
|
||||
}
|
||||
}
|
||||
return target;
|
||||
};
|
||||
|
||||
var WORKER_FILE = {
|
||||
wav: "WebAudioRecorderWav.js",
|
||||
ogg: "WebAudioRecorderOgg.js",
|
||||
mp3: "WebAudioRecorderMp3.js"
|
||||
};
|
||||
|
||||
// default configs
|
||||
var CONFIGS = {
|
||||
workerDir: "/", // worker scripts dir (end with /)
|
||||
numChannels: 2, // number of channels
|
||||
encoding: "wav", // encoding (can be changed at runtime)
|
||||
|
||||
// runtime options
|
||||
options: {
|
||||
timeLimit: 300, // recording time limit (sec)
|
||||
encodeAfterRecord: false, // process encoding after recording
|
||||
progressInterval: 1000, // encoding progress report interval (millisec)
|
||||
bufferSize: undefined, // buffer size (use browser default)
|
||||
|
||||
// encoding-specific options
|
||||
wav: {
|
||||
mimeType: "audio/wav"
|
||||
},
|
||||
ogg: {
|
||||
mimeType: "audio/ogg",
|
||||
quality: 0.5 // (VBR only): quality = [-0.1 .. 1]
|
||||
},
|
||||
mp3: {
|
||||
mimeType: "audio/mpeg",
|
||||
bitRate: 160 // (CBR only): bit rate = [64 .. 320]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// constructor
|
||||
var WebAudioRecorder = function(sourceNode, configs) {
|
||||
extend(this, CONFIGS, configs || {});
|
||||
this.context = sourceNode.context;
|
||||
if (this.context.createScriptProcessor == null)
|
||||
this.context.createScriptProcessor = this.context.createJavaScriptNode;
|
||||
this.input = this.context.createGain();
|
||||
sourceNode.connect(this.input);
|
||||
this.buffer = [];
|
||||
this.initWorker();
|
||||
};
|
||||
|
||||
// instance methods
|
||||
extend(WebAudioRecorder.prototype, {
|
||||
isRecording: function() { return this.processor != null; },
|
||||
|
||||
setEncoding: function(encoding) {
|
||||
if (this.isRecording())
|
||||
this.error("setEncoding: cannot set encoding during recording");
|
||||
else if (this.encoding !== encoding) {
|
||||
this.encoding = encoding;
|
||||
this.initWorker();
|
||||
}
|
||||
},
|
||||
|
||||
setOptions: function(options) {
|
||||
if (this.isRecording())
|
||||
this.error("setOptions: cannot set options during recording");
|
||||
else {
|
||||
extend(this.options, options);
|
||||
this.worker.postMessage({ command: "options", options: this.options });
|
||||
}
|
||||
},
|
||||
|
||||
startRecording: function() {
|
||||
if (this.isRecording())
|
||||
this.error("startRecording: previous recording is running");
|
||||
else {
|
||||
var numChannels = this.numChannels,
|
||||
buffer = this.buffer,
|
||||
worker = this.worker;
|
||||
this.processor = this.context.createScriptProcessor(
|
||||
this.options.bufferSize,
|
||||
this.numChannels, this.numChannels);
|
||||
this.input.connect(this.processor);
|
||||
this.processor.connect(this.context.destination);
|
||||
this.processor.onaudioprocess = function(event) {
|
||||
for (var ch = 0; ch < numChannels; ++ch)
|
||||
buffer[ch] = event.inputBuffer.getChannelData(ch);
|
||||
worker.postMessage({ command: "record", buffer: buffer });
|
||||
};
|
||||
this.worker.postMessage({
|
||||
command: "start",
|
||||
bufferSize: this.processor.bufferSize
|
||||
});
|
||||
this.startTime = Date.now();
|
||||
}
|
||||
},
|
||||
|
||||
recordingTime: function() {
|
||||
return this.isRecording() ? (Date.now() - this.startTime) * 0.001 : null;
|
||||
},
|
||||
|
||||
cancelRecording: function() {
|
||||
if (this.isRecording()) {
|
||||
this.input.disconnect();
|
||||
this.processor.disconnect();
|
||||
delete this.processor;
|
||||
this.worker.postMessage({ command: "cancel" });
|
||||
} else
|
||||
this.error("cancelRecording: no recording is running");
|
||||
},
|
||||
|
||||
finishRecording: function() {
|
||||
if (this.isRecording()) {
|
||||
this.input.disconnect();
|
||||
this.processor.disconnect();
|
||||
delete this.processor;
|
||||
this.worker.postMessage({ command: "finish" });
|
||||
} else
|
||||
this.error("finishRecording: no recording is running");
|
||||
},
|
||||
|
||||
cancelEncoding: function() {
|
||||
if (this.options.encodeAfterRecord)
|
||||
if (this.isRecording())
|
||||
this.error("cancelEncoding: recording is not finished");
|
||||
else {
|
||||
this.onEncodingCanceled(this);
|
||||
this.initWorker();
|
||||
}
|
||||
else
|
||||
this.error("cancelEncoding: invalid method call");
|
||||
},
|
||||
|
||||
initWorker: function() {
|
||||
if (this.worker != null)
|
||||
this.worker.terminate();
|
||||
this.onEncoderLoading(this, this.encoding);
|
||||
this.worker = new Worker(this.workerDir + WORKER_FILE[this.encoding]);
|
||||
var _this = this;
|
||||
this.worker.onmessage = function(event) {
|
||||
var data = event.data;
|
||||
switch (data.command) {
|
||||
case "loaded":
|
||||
_this.onEncoderLoaded(_this, _this.encoding);
|
||||
break;
|
||||
case "timeout":
|
||||
_this.onTimeout(_this);
|
||||
break;
|
||||
case "progress":
|
||||
_this.onEncodingProgress(_this, data.progress);
|
||||
break;
|
||||
case "complete":
|
||||
_this.onComplete(_this, data.blob);
|
||||
break;
|
||||
case "error":
|
||||
_this.error(data.message);
|
||||
}
|
||||
};
|
||||
this.worker.postMessage({
|
||||
command: "init",
|
||||
config: {
|
||||
sampleRate: this.context.sampleRate,
|
||||
numChannels: this.numChannels
|
||||
},
|
||||
options: this.options
|
||||
});
|
||||
},
|
||||
|
||||
error: function(message) {
|
||||
this.onError(this, "WebAudioRecorder.js:" + message);
|
||||
},
|
||||
|
||||
// event handlers
|
||||
onEncoderLoading: function(recorder, encoding) {},
|
||||
onEncoderLoaded: function(recorder, encoding) {},
|
||||
onTimeout: function(recorder) { recorder.finishRecording(); },
|
||||
onEncodingProgress: function (recorder, progress) {},
|
||||
onEncodingCanceled: function(recorder) {},
|
||||
onComplete: function(recorder, blob) {
|
||||
recorder.onError(recorder, "WebAudioRecorder.js: You must override .onComplete event");
|
||||
},
|
||||
onError: function(recorder, message) { console.log(message); }
|
||||
});
|
||||
|
||||
window.WebAudioRecorder = WebAudioRecorder;
|
||||
})(window);
|
@ -0,0 +1,91 @@
|
||||
importScripts("Mp3LameEncoder.min.js");
|
||||
|
||||
var NUM_CH = 2, // constant
|
||||
sampleRate = 44100,
|
||||
options = undefined,
|
||||
maxBuffers = undefined,
|
||||
encoder = undefined,
|
||||
recBuffers = undefined,
|
||||
bufferCount = 0;
|
||||
|
||||
function error(message) {
|
||||
self.postMessage({ command: "error", message: "mp3: " + message });
|
||||
}
|
||||
|
||||
function init(data) {
|
||||
if (data.config.numChannels === NUM_CH) {
|
||||
sampleRate = data.config.sampleRate;
|
||||
options = data.options;
|
||||
} else
|
||||
error("numChannels must be " + NUM_CH);
|
||||
};
|
||||
|
||||
function setOptions(opt) {
|
||||
if (encoder || recBuffers)
|
||||
error("cannot set options during recording");
|
||||
else
|
||||
options = opt;
|
||||
}
|
||||
|
||||
function start(bufferSize) {
|
||||
maxBuffers = Math.ceil(options.timeLimit * sampleRate / bufferSize);
|
||||
if (options.encodeAfterRecord)
|
||||
recBuffers = [];
|
||||
else
|
||||
encoder = new Mp3LameEncoder(sampleRate, options.mp3.bitRate);
|
||||
}
|
||||
|
||||
function record(buffer) {
|
||||
if (bufferCount++ < maxBuffers)
|
||||
if (encoder)
|
||||
encoder.encode(buffer);
|
||||
else
|
||||
recBuffers.push(buffer);
|
||||
else
|
||||
self.postMessage({ command: "timeout" });
|
||||
};
|
||||
|
||||
function postProgress(progress) {
|
||||
self.postMessage({ command: "progress", progress: progress });
|
||||
};
|
||||
|
||||
function finish() {
|
||||
if (recBuffers) {
|
||||
postProgress(0);
|
||||
encoder = new Mp3LameEncoder(sampleRate, options.mp3.bitRate);
|
||||
var timeout = Date.now() + options.progressInterval;
|
||||
while (recBuffers.length > 0) {
|
||||
encoder.encode(recBuffers.shift());
|
||||
var now = Date.now();
|
||||
if (now > timeout) {
|
||||
postProgress((bufferCount - recBuffers.length) / bufferCount);
|
||||
timeout = now + options.progressInterval;
|
||||
}
|
||||
}
|
||||
postProgress(1);
|
||||
}
|
||||
self.postMessage({
|
||||
command: "complete",
|
||||
blob: encoder.finish(options.mp3.mimeType)
|
||||
});
|
||||
cleanup();
|
||||
};
|
||||
|
||||
function cleanup() {
|
||||
encoder = recBuffers = undefined;
|
||||
bufferCount = 0;
|
||||
}
|
||||
|
||||
self.onmessage = function(event) {
|
||||
var data = event.data;
|
||||
switch (data.command) {
|
||||
case "init": init(data); break;
|
||||
case "options": setOptions(data.options); break;
|
||||
case "start": start(data.bufferSize); break;
|
||||
case "record": record(data.buffer); break;
|
||||
case "finish": finish(); break;
|
||||
case "cancel": cleanup();
|
||||
}
|
||||
};
|
||||
|
||||
self.postMessage({ command: "loaded" });
|
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" viewBox="0 0 48 48"><path d="M24 28c3.31 0 5.98-2.69 5.98-6L30 10c0-3.32-2.68-6-6-6-3.31 0-6 2.68-6 6v12c0 3.31 2.69 6 6 6zm10.6-6c0 6-5.07 10.2-10.6 10.2-5.52 0-10.6-4.2-10.6-10.2H10c0 6.83 5.44 12.47 12 13.44V42h4v-6.56c6.56-.97 12-6.61 12-13.44h-3.4z"/></svg>
|
After Width: | Height: | Size: 325 B |
@ -0,0 +1 @@
|
||||
../components/mp3lameencoder/lib/Mp3LameEncoder.js
|
@ -0,0 +1 @@
|
||||
../components/webaudiorecorder/lib/WebAudioRecorderMp3.js
|
@ -0,0 +1,74 @@
|
||||
/*
|
||||
* vim: ts=4:sw=4:expandtab
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
window.Whisper = window.Whisper || {};
|
||||
|
||||
Whisper.RecorderView = Whisper.View.extend({
|
||||
className: 'recorder clearfix',
|
||||
templateName: 'recorder',
|
||||
initialize: function() {
|
||||
this.startTime = Date.now();
|
||||
this.interval = setInterval(this.updateTime.bind(this), 1000);
|
||||
this.start();
|
||||
},
|
||||
events: {
|
||||
'click .close': 'close',
|
||||
'click .finish': 'finish',
|
||||
'close': 'close'
|
||||
},
|
||||
updateTime: function() {
|
||||
var duration = moment.duration(Date.now() - this.startTime, 'ms');
|
||||
var minutes = '' + Math.trunc(duration.asMinutes());
|
||||
var seconds = '' + duration.seconds();
|
||||
if (seconds.length < 2) {
|
||||
seconds = '0' + seconds;
|
||||
}
|
||||
this.$('.time').text(minutes + ':' + seconds);
|
||||
},
|
||||
close: function() {
|
||||
if (this.recorder.isRecording()) {
|
||||
this.recorder.cancelRecording();
|
||||
}
|
||||
if (this.interval) {
|
||||
clearInterval(this.interval);
|
||||
}
|
||||
this.source.disconnect();
|
||||
if (this.context) {
|
||||
this.context.close().then(function() {
|
||||
console.log('audio context closed');
|
||||
});
|
||||
}
|
||||
this.remove();
|
||||
this.trigger('closed');
|
||||
},
|
||||
finish: function() {
|
||||
this.recorder.finishRecording();
|
||||
this.close();
|
||||
},
|
||||
handleBlob: function(recorder, blob) {
|
||||
if (blob) {
|
||||
this.trigger('send', blob);
|
||||
}
|
||||
},
|
||||
start: function() {
|
||||
this.context = new AudioContext();
|
||||
this.input = this.context.createGain();
|
||||
this.recorder = new WebAudioRecorder(this.input, {
|
||||
encoding: 'mp3',
|
||||
workerDir: 'js/' // must end with slash
|
||||
});
|
||||
this.recorder.onComplete = this.handleBlob.bind(this);
|
||||
this.recorder.onError = this.onError;
|
||||
navigator.webkitGetUserMedia({ audio: true }, function(stream) {
|
||||
this.source = this.context.createMediaStreamSource(stream);
|
||||
this.source.connect(this.input);
|
||||
}.bind(this), this.onError);
|
||||
this.recorder.startRecording();
|
||||
},
|
||||
onError: function(error) {
|
||||
console.log(error);
|
||||
}
|
||||
});
|
||||
})();
|
@ -0,0 +1,88 @@
|
||||
.capture-audio {
|
||||
text-align: center;
|
||||
|
||||
.microphone {
|
||||
height: 36px;
|
||||
width: 36px;
|
||||
text-align: center;
|
||||
opacity: 0.5;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
|
||||
&:focus, &:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
width: 24px;
|
||||
@include color-svg('/images/microphone.svg', $grey);
|
||||
}
|
||||
}
|
||||
}
|
||||
.recorder {
|
||||
background: $grey_l;
|
||||
|
||||
button {
|
||||
float: right;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 36px;
|
||||
margin-left: 5px;
|
||||
opacity: 0.5;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
|
||||
&:focus, &:hover {
|
||||
opacity: 1.0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
width: $button-height;
|
||||
height: $button-height;
|
||||
}
|
||||
}
|
||||
|
||||
.finish {
|
||||
background: lighten($green, 20%);
|
||||
border: 1px solid $green;
|
||||
|
||||
.icon { @include color-svg('/images/check.svg', $green); }
|
||||
}
|
||||
|
||||
.close {
|
||||
background: lighten($red, 20%);
|
||||
border: 1px solid $red;
|
||||
|
||||
.icon { @include color-svg('/images/x.svg', $red); }
|
||||
}
|
||||
|
||||
.time {
|
||||
color: $grey;
|
||||
float: right;
|
||||
line-height: 36px;
|
||||
padding: 0 10px;
|
||||
|
||||
@keyframes pulse {
|
||||
0% { opacity:0; }
|
||||
50% { opacity:1; }
|
||||
100% { opacity:0; }
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
border-radius: 10px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #f00;
|
||||
margin-right: 10px;
|
||||
opacity: 0;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue