使用 Content-Range 在 Node.js 中流式传输音频
Streaming audio in Node.js with Content-Range
我正在 Node.js 中使用流媒体服务器来流式传输 MP3 文件。虽然整个文件流式传输没问题,但我无法使用 Content-Range
header 来流式传输文件以寻找开始位置并使用结束位置。
我使用ffprobe
like
从秒开始和结束字节计算
ffprobe -i /audio/12380187.mp3 -show_frames -show_entries frame=pkt_pos -of default=noprint_wrappers=1:nokey=1 -hide_banner -loglevel panic -read_intervals 20%+#1
在这种情况下,这将给我从 10 秒到第一个下一个数据包的确切字节数。
这在Node.js中变得和
一样简单
const args = [
'-hide_banner',
'-loglevel', loglevel,
'-show_frames',//Display information about each frame
'-show_entries', 'frame=pkt_pos',// Display only information about byte position
'-of', 'default=noprint_wrappers=1:nokey=1',//Don't want to print the key and the section header and footer
'-read_intervals', seconds+'%+#1', //Read only 1 packet after seeking to position 01:23
'-print_format', 'json',
'-v', 'quiet',
'-i', fpath
];
const opts = {
cwd: self._options.tempDir
};
const cb = (error, stdout) => {
if (error)
return reject(error);
try {
const outputObj = JSON.parse(stdout);
return resolve(outputObj);
} catch (ex) {
return reject(ex);
}
};
cp.execFile('ffprobe', args, opts, cb)
.on('error', reject);
});
现在我有了开始和结束字节,我的媒体服务器将以这种方式从传递给它的自定义值中获取范围,例如 bytes=120515-240260
var getRange = function (req, total) {
var range = [0, total, 0];
var rinfo = req.headers ? req.headers.range : null;
if (rinfo) {
var rloc = rinfo.indexOf('bytes=');
if (rloc >= 0) {
var ranges = rinfo.substr(rloc + 6).split('-');
try {
range[0] = parseInt(ranges[0]);
if (ranges[1] && ranges[1].length) {
range[1] = parseInt(ranges[1]);
range[1] = range[1] < 16 ? 16 : range[1];
}
} catch (e) {}
}
if (range[1] == total)
range[1]--;
range[2] = total;
}
return range;
};
此时我将得到这个范围 [ 120515, 240260, 4724126 ]
,我喜欢 [startBytes,endBytes,totalDurationInBytes]
因此我可以创建一个通过该范围的文件读取流:
var file = fs.createReadStream(path, {start: range[0], end: range[1]});
然后使用
撰写回复header
var header = {
'Content-Length': range[1],
'Content-Type': type,
'Access-Control-Allow-Origin': req.headers.origin || "*",
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'POST, GET, OPTIONS'
};
if (range[2]) {
header['Expires'] = 0;
header['Pragma'] = 'no-cache';
header['Cache-Control']= 'no-cache, no-store, must-revalidate';
header['Accept-Ranges'] = 'bytes';
header['Content-Range'] = 'bytes ' + range[0] + '-' + range[1] + '/' + total;
header['Content-Length'] = range[2];
//HTTP/1.1 206 Partial Content
res.writeHead(206, header);
} else {
res.writeHead(200, header);
}
所以得到
{
"Content-Length": 4724126,
"Content-Type": "audio/mpeg",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "POST, GET, OPTIONS",
"Accept-Ranges": "bytes",
"Content-Range": "bytes 120515-240260/4724126"
}
在将读取流传输到输出之前
file.pipe(res);
问题是我的浏览器没有在 HTML5 <audio>
标签中获得任何音频,而它在不使用任何 Content-Range
[=79] 时流式传输内容=].
Here 您可以从节点 api 中看到 ReadStream
object 的转储,显示范围如何正常
start: 120515,
end: 240260,
autoClose: true,
pos: 120515
那么浏览器端发生了什么阻止加载文件?
[更新]
事实证明,它在 Safari 中有效,但在 Google 的 Chrome 中无效!然后我可以假设 Content-Range
它设计正确,但 Chrome 有一些缺陷。
现在规范是由 rfc2616 制定的,我严格遵循 byte-range-resp-spec
的规范,所以我通过了
"Accept-Ranges": "bytes",
"Content-Range": "bytes 120515-240260/4724126"
根据 RFC 规范,这也适用于 Chrome。它也应该按照 Mozilla 文档的规定工作 as-it-is here
我正在使用 expressjs
框架,我是这样设计的:
// Readable Streams Storage Class
class FileReadStreams {
constructor() {
this._streams = {};
}
make(file, options = null) {
return options ?
fs.createReadStream(file, options)
: fs.createReadStream(file);
}
get(file) {
return this._streams[file] || this.set(file);
}
set(file) {
return this._streams[file] = this.make(file);
}
}
const readStreams = new FileReadStreams();
// Getting file stats and caching it to avoid disk i/o
function getFileStat(file, callback) {
let cacheKey = ['File', 'stat', file].join(':');
cache.get(cacheKey, function(err, stat) {
if(stat) {
return callback(null, stat);
}
fs.stat(file, function(err, stat) {
if(err) {
return callback(err);
}
cache.set(cacheKey, stat);
callback(null, stat);
});
});
}
// Streaming whole file
function streamFile(file, req, res) {
getFileStat(file, function(err, stat) {
if(err) {
console.error(err);
return res.status(404).end();
}
let bufferSize = 1024 * 1024;
res.writeHead(200, {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': 0,
'Content-Type': 'audio/mpeg',
'Content-Length': stat.size
});
readStreams.make(file, {bufferSize}).pipe(res);
});
}
// Streaming chunk
function streamFileChunked(file, req, res) {
getFileStat(file, function(err, stat) {
if(err) {
console.error(err);
return res.status(404).end();
}
let chunkSize = 1024 * 1024;
if(stat.size > chunkSize * 2) {
chunkSize = Math.ceil(stat.size * 0.25);
}
let range = (req.headers.range) ? req.headers.range.replace(/bytes=/, "").split("-") : [];
range[0] = range[0] ? parseInt(range[0], 10) : 0;
range[1] = range[1] ? parseInt(range[1], 10) : range[0] + chunkSize;
if(range[1] > stat.size - 1) {
range[1] = stat.size - 1;
}
range = {start: range[0], end: range[1]};
let stream = readStreams.make(file, range);
res.writeHead(206, {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': 0,
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Content-Range': 'bytes ' + range.start + '-' + range.end + '/' + stat.size,
'Content-Length': range.end - range.start + 1,
});
stream.pipe(res);
});
}
router.get('/:file/stream', (req, res) => {
const file = path.join('path/to/mp3/', req.params.file+'.mp3');
if(/firefox/i.test(req.headers['user-agent'])) {
return streamFile(file, req, res);
}
streamFileChunked(file, req, res);
});
站点的完整来源here
尝试修复您的代码:
这将强制浏览器以分块方式处理资源。
var header = {
'Content-Length': range[1],
'Content-Type': type,
'Access-Control-Allow-Origin': req.headers.origin || "*",
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'POST, GET, OPTIONS',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': 0
};
if(/firefox/i.test(req.headers['user-agent'])) {
res.writeHead(200, header);
}
else {
header['Accept-Ranges'] = 'bytes';
header['Content-Range'] = 'bytes ' + range[0] + '-' + range[1] + '/' + total;
header['Content-Length'] = range[2];
res.writeHead(206, header);
}
我正在 Node.js 中使用流媒体服务器来流式传输 MP3 文件。虽然整个文件流式传输没问题,但我无法使用 Content-Range
header 来流式传输文件以寻找开始位置并使用结束位置。
我使用ffprobe
like
ffprobe -i /audio/12380187.mp3 -show_frames -show_entries frame=pkt_pos -of default=noprint_wrappers=1:nokey=1 -hide_banner -loglevel panic -read_intervals 20%+#1
在这种情况下,这将给我从 10 秒到第一个下一个数据包的确切字节数。
这在Node.js中变得和
一样简单 const args = [
'-hide_banner',
'-loglevel', loglevel,
'-show_frames',//Display information about each frame
'-show_entries', 'frame=pkt_pos',// Display only information about byte position
'-of', 'default=noprint_wrappers=1:nokey=1',//Don't want to print the key and the section header and footer
'-read_intervals', seconds+'%+#1', //Read only 1 packet after seeking to position 01:23
'-print_format', 'json',
'-v', 'quiet',
'-i', fpath
];
const opts = {
cwd: self._options.tempDir
};
const cb = (error, stdout) => {
if (error)
return reject(error);
try {
const outputObj = JSON.parse(stdout);
return resolve(outputObj);
} catch (ex) {
return reject(ex);
}
};
cp.execFile('ffprobe', args, opts, cb)
.on('error', reject);
});
现在我有了开始和结束字节,我的媒体服务器将以这种方式从传递给它的自定义值中获取范围,例如 bytes=120515-240260
var getRange = function (req, total) {
var range = [0, total, 0];
var rinfo = req.headers ? req.headers.range : null;
if (rinfo) {
var rloc = rinfo.indexOf('bytes=');
if (rloc >= 0) {
var ranges = rinfo.substr(rloc + 6).split('-');
try {
range[0] = parseInt(ranges[0]);
if (ranges[1] && ranges[1].length) {
range[1] = parseInt(ranges[1]);
range[1] = range[1] < 16 ? 16 : range[1];
}
} catch (e) {}
}
if (range[1] == total)
range[1]--;
range[2] = total;
}
return range;
};
此时我将得到这个范围 [ 120515, 240260, 4724126 ]
,我喜欢 [startBytes,endBytes,totalDurationInBytes]
因此我可以创建一个通过该范围的文件读取流:
var file = fs.createReadStream(path, {start: range[0], end: range[1]});
然后使用
撰写回复header var header = {
'Content-Length': range[1],
'Content-Type': type,
'Access-Control-Allow-Origin': req.headers.origin || "*",
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'POST, GET, OPTIONS'
};
if (range[2]) {
header['Expires'] = 0;
header['Pragma'] = 'no-cache';
header['Cache-Control']= 'no-cache, no-store, must-revalidate';
header['Accept-Ranges'] = 'bytes';
header['Content-Range'] = 'bytes ' + range[0] + '-' + range[1] + '/' + total;
header['Content-Length'] = range[2];
//HTTP/1.1 206 Partial Content
res.writeHead(206, header);
} else {
res.writeHead(200, header);
}
所以得到
{
"Content-Length": 4724126,
"Content-Type": "audio/mpeg",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
"Access-Control-Allow-Headers": "POST, GET, OPTIONS",
"Accept-Ranges": "bytes",
"Content-Range": "bytes 120515-240260/4724126"
}
在将读取流传输到输出之前
file.pipe(res);
问题是我的浏览器没有在 HTML5 <audio>
标签中获得任何音频,而它在不使用任何 Content-Range
[=79] 时流式传输内容=].
Here 您可以从节点 api 中看到 ReadStream
object 的转储,显示范围如何正常
start: 120515,
end: 240260,
autoClose: true,
pos: 120515
那么浏览器端发生了什么阻止加载文件?
[更新]
事实证明,它在 Safari 中有效,但在 Google 的 Chrome 中无效!然后我可以假设 Content-Range
它设计正确,但 Chrome 有一些缺陷。
现在规范是由 rfc2616 制定的,我严格遵循 byte-range-resp-spec
的规范,所以我通过了
"Accept-Ranges": "bytes",
"Content-Range": "bytes 120515-240260/4724126"
根据 RFC 规范,这也适用于 Chrome。它也应该按照 Mozilla 文档的规定工作 as-it-is here
我正在使用 expressjs
框架,我是这样设计的:
// Readable Streams Storage Class
class FileReadStreams {
constructor() {
this._streams = {};
}
make(file, options = null) {
return options ?
fs.createReadStream(file, options)
: fs.createReadStream(file);
}
get(file) {
return this._streams[file] || this.set(file);
}
set(file) {
return this._streams[file] = this.make(file);
}
}
const readStreams = new FileReadStreams();
// Getting file stats and caching it to avoid disk i/o
function getFileStat(file, callback) {
let cacheKey = ['File', 'stat', file].join(':');
cache.get(cacheKey, function(err, stat) {
if(stat) {
return callback(null, stat);
}
fs.stat(file, function(err, stat) {
if(err) {
return callback(err);
}
cache.set(cacheKey, stat);
callback(null, stat);
});
});
}
// Streaming whole file
function streamFile(file, req, res) {
getFileStat(file, function(err, stat) {
if(err) {
console.error(err);
return res.status(404).end();
}
let bufferSize = 1024 * 1024;
res.writeHead(200, {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': 0,
'Content-Type': 'audio/mpeg',
'Content-Length': stat.size
});
readStreams.make(file, {bufferSize}).pipe(res);
});
}
// Streaming chunk
function streamFileChunked(file, req, res) {
getFileStat(file, function(err, stat) {
if(err) {
console.error(err);
return res.status(404).end();
}
let chunkSize = 1024 * 1024;
if(stat.size > chunkSize * 2) {
chunkSize = Math.ceil(stat.size * 0.25);
}
let range = (req.headers.range) ? req.headers.range.replace(/bytes=/, "").split("-") : [];
range[0] = range[0] ? parseInt(range[0], 10) : 0;
range[1] = range[1] ? parseInt(range[1], 10) : range[0] + chunkSize;
if(range[1] > stat.size - 1) {
range[1] = stat.size - 1;
}
range = {start: range[0], end: range[1]};
let stream = readStreams.make(file, range);
res.writeHead(206, {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': 0,
'Content-Type': 'audio/mpeg',
'Accept-Ranges': 'bytes',
'Content-Range': 'bytes ' + range.start + '-' + range.end + '/' + stat.size,
'Content-Length': range.end - range.start + 1,
});
stream.pipe(res);
});
}
router.get('/:file/stream', (req, res) => {
const file = path.join('path/to/mp3/', req.params.file+'.mp3');
if(/firefox/i.test(req.headers['user-agent'])) {
return streamFile(file, req, res);
}
streamFileChunked(file, req, res);
});
站点的完整来源here
尝试修复您的代码:
这将强制浏览器以分块方式处理资源。
var header = {
'Content-Length': range[1],
'Content-Type': type,
'Access-Control-Allow-Origin': req.headers.origin || "*",
'Access-Control-Allow-Methods': 'POST, GET, OPTIONS',
'Access-Control-Allow-Headers': 'POST, GET, OPTIONS',
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
'Expires': 0
};
if(/firefox/i.test(req.headers['user-agent'])) {
res.writeHead(200, header);
}
else {
header['Accept-Ranges'] = 'bytes';
header['Content-Range'] = 'bytes ' + range[0] + '-' + range[1] + '/' + total;
header['Content-Length'] = range[2];
res.writeHead(206, header);
}