由于缺乏不同的描述,我正在尝试实现离线媒体环境。
这个概念是创建 1 秒的 Blob 记录媒体,能够
HTMLMediaElement 上独立播放 1 秒的 BlobBlob 的完整媒体资源问题是,一旦 Blob 连接起来,媒体资源就不会在 HTMLMedia 元素上使用 Blob URL 或 MediaSource.
创建的 Blob URL 仅播放连接的 Blob 的 1 秒。 MediaSource 抛出两个异常
DOMException: Failed to execute 'addSourceBuffer' on 'MediaSource': The MediaSource's readyState is not 'open'
和
DOMException: Failed to execute 'appendBuffer' on 'SourceBuffer': This SourceBuffer has been removed from the parent media source.
如何正确编码串联的 Blob 或以其他方式实现解决方法以将媒体片段作为单个重组媒体资源播放?
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<script>
const src = "https://nickdesaulniers.github.io/netfix/demo/frag_bunny.mp4";
fetch(src)
.then(response => response.blob())
.then(blob => {
const blobURL = URL.createObjectURL(blob);
const chunks = [];
const mimeCodec = "vdeo/webm; codecs=opus";
let duration;
let media = document.createElement("video");
media.onloadedmetadata = () => {
media.onloadedmetadata = null;
duration = Math.ceil(media.duration);
let arr = Array.from({
length: duration
}, (_, index) => index);
// record each second of media
arr.reduce((p, index) =>
p.then(() =>
new Promise(resolve => {
let recorder;
let video = document.createElement("video");
video.onpause = e => {
video.onpause = null;
console.log(e);
recorder.stop();
}
video.oncanplay = () => {
video.oncanplay = null;
video.play();
let stream = video.captureStream();
recorder = new MediaRecorder(stream);
recorder.start();
recorder.ondataavailable = e => {
console.log("data event", recorder.state, e.data);
chunks.push(e.data);
}
recorder.onstop = e => {
resolve();
}
}
video.src = `${blobURL}#t=${index},${index+1}`;
})
), Promise.resolve())
.then(() => {
console.log(chunks);
let video = document.createElement("video");
video.controls = true;
document.body.appendChild(video);
let select = document.createElement("select");
document.body.appendChild(select);
let option = new Option("select a segment");
select.appendChild(option);
for (let chunk of chunks) {
let index = chunks.indexOf(chunk);
let option = new Option(`Play ${index}-${index + 1} seconds of media`, index);
select.appendChild(option)
}
let fullMedia = new Blob(chunks, {
type: mimeCodec
});
let opt = new Option("Play full media", "Play full media");
select.appendChild(opt);
select.onchange = () => {
if (select.value !== "Play full media") {
video.src = URL.createObjectURL(chunks[select.value])
} else {
const mediaSource = new MediaSource();
video.src = URL.createObjectURL(mediaSource);
mediaSource.addEventListener("sourceopen", sourceOpen);
function sourceOpen(event) {
// if the media type is supported by `mediaSource`
// fetch resource, begin stream read,
// append stream to `sourceBuffer`
if (MediaSource.isTypeSupported(mimeCodec)) {
var sourceBuffer = mediaSource.addSourceBuffer(mimeCodec);
// set `sourceBuffer` `.mode` to `"sequence"`
sourceBuffer.mode = "segments";
fetch(URL.createObjectURL(fullMedia))
// return `ReadableStream` of `response`
.then(response => response.body.getReader())
.then(reader => {
const processStream = (data) => {
if (data.done) {
return;
}
// append chunk of stream to `sourceBuffer`
sourceBuffer.appendBuffer(data.value);
}
// at `sourceBuffer` `updateend` call `reader.read()`,
// to read next chunk of stream, append chunk to
// `sourceBuffer`
sourceBuffer.addEventListener("updateend", function() {
reader.read().then(processStream);
});
// start processing stream
reader.read().then(processStream);
// do stuff `reader` is closed,
// read of stream is complete
return reader.closed.then(() => {
// signal end of stream to `mediaSource`
mediaSource.endOfStream();
return mediaSource.readyState;
})
})
// do stuff when `reader.closed`, `mediaSource` stream ended
.then(msg => console.log(msg))
.catch(err => console.log(err))
}
// if `mimeCodec` is not supported by `MediaSource`
else {
alert(mimeCodec + " not supported");
}
};
}
}
})
}
media.src = blobURL;
})
</script>
</body>
</html>
在 select change 事件中使用 Blob URL at else 语句,仅播放媒体资源的第一秒
video.src = URL.createObjectURL(fullMedia);
plnkr http://plnkr.co/edit/dNznvxe504JX7RWY658T?p=preview版本 1 Blob URL,版本 2 MediaSource
最佳答案
目前没有针对视频编辑的 Web API。
MediaStream 和 MediaRecorder API 旨在处理实时源。
由于视频文件的结构,您不能只切一部分来制作新视频,也不能将小视频文件拼接起来制作更长的视频。在这两种情况下,您都需要重建其元数据才能制作新的视频文件。
当前唯一能够生成 MediaFiles 的 API 是 MediaRecorder。
目前只有两个 MediaRecorder API 的实现者,但它们在两个不同的容器中支持大约 3 种不同的编解码器,这意味着您需要自己构建至少 5 个元数据解析器才能仅支持当前的实现(这将保留数量在增长,并且可能需要随着实现的更新而更新)。
听起来像是一项艰巨的工作。
也许传入的 WebAssembly API 将允许我们将 ffmpeg 移植到浏览器,这将使它变得更简单,但我必须承认我根本不知道 WA,所以我什至不确定它是否真的可行.
我听到你说“好吧,没有专门为此而制作的工具,但我们是黑客,我们还有其他功能强大的工具。”
嗯,是。如果我们真的愿意这样做,我们可以破解一些东西......
如前所述,MediaStream 和 MediaRecorder 用于实时视频。因此,我们可以使用 [HTMLVideoElement | HTMLCanvasElement].captureStream() 将静态视频文件转换为实时流。方法。
借助 MediaRecorder API,我们还可以将这些直播流记录到静态文件中。
但是,我们不能做的是将当前的流源更改为 MediaRecorder。
所以为了将小视频文件合并成一个更长的视频文件,我们需要
<video>元素<video> <canvas> 上的元素所需顺序中的元素<video> 提供给 AudioContext 的流源元素但这意味着合并实际上是对所有视频的重新录制,而且这只能实时完成(速度=x1)
这是一个现场概念验证,我们首先将原始视频文件分成多个较小的部分,将这些部分打乱以模仿一些蒙太奇,然后创建一个基于 Canvas 的播放器,也能够记录这个蒙太奇并导出它。
NotaBene:这是第一个版本,我仍然有很多错误(特别是在 Firefox 中,在 chrome 中应该几乎可以正常工作)。
(() => {
if (!('MediaRecorder' in window)) {
throw new Error('unsupported browser');
}
// some global params
const CHUNK_DURATION = 1000;
const MAX_SLICES = 15; // get only 15 slices
const FPS = 30;
async function init() {
const url = 'https://nickdesaulniers.github.io/netfix/demo/frag_bunny.mp4';
const slices = await getSlices(url); // slice the original media in longer chunks
mess_up_array(slices); // Let's shuffle these slices,
// otherwise there is no point merging it in a new file
generateSelect(slices); // displays each chunk independentely
window.player = new SlicePlayer(slices); // init our player
};
const SlicePlayer = class {
/*
@args: Array of populated HTMLVideoElements
*/
constructor(parts) {
this.parts = parts;
this.initVideoContext();
this.initAudioContext();
this.currentIndex = 0; // to know which video we'll play
this.currentTime = 0;
this.duration = parts.reduce((a, b) => b._duration + a, 0); // the sum of all parts' durations
// (see below why "_")
this.initDOM();
// attach our onended callback only on the last vid
this.parts[this.parts.length - 1].onended = e => this.onended();
this.resetAll(); // set all videos' currentTime to 0 + draw first frame
}
initVideoContext() {
const c = this.canvas = document.createElement('canvas');
c.width = this.parts[0].videoWidth;
c.height = this.parts[0].videoHeight;
this.v_ctx = c.getContext('2d');
}
initAudioContext() {
const a = this.a_ctx = new AudioContext();
const gain = this.volume_node = a.createGain();
gain.connect(a.destination);
// extract the audio from our video elements so that we can record it
this.audioSources = this.parts.map(v => a.createMediaElementSource(v));
this.audioSources.forEach(s => s.connect(gain));
}
initDOM() {
// all DOM things...
canvas_player_timeline.max = this.duration;
canvas_player_cont.appendChild(this.canvas);
canvas_player_play_btn.onclick = e => this.startVid(this.currentIndex);
canvas_player_cont.style.display = 'inline-block';
canvas_player_timeline.oninput = e => {
if (!this.recording)
this.onseeking(e);
};
canvas_player_record_btn.onclick = e => this.record();
}
resetAll() {
this.currentTime = canvas_player_timeline.value = 0;
// when the first part as actually been reset to start
this.parts[0].onseeked = e => {
this.parts[0].onseeked = null;
this.draw(0); // draw it
};
this.parts.forEach(v => v.currentTime = 0);
if (this.playing && this.stopLoop) {
this.playing = false;
this.stopLoop();
}
}
startVid(index) { // starts playing the video at given index
if (index > this.parts.length - 1) { // that was the last one
this.onended();
return;
}
this.playing = true;
this.currentIndex = index; // update our currentIndex
this.parts[index].play().then(() => {
// try to avoid at maximum the gaps between different parts
if (this.recording && this.recorder.state === 'paused') {
this.recorder.resume();
}
});
this.startLoop();
}
startNext() { // starts the next part before the current one actually ended
const nextPart = this.parts[this.currentIndex + 1];
if (!nextPart) { // current === last
return;
}
this.playing = true;
if (!nextPart.paused) { // already playing ?
return;
}
// try to avoid at maximum the gaps between different parts
if (this.recording && this.recorder && this.recorder.state === 'recording') {
this.recorder.pause();
}
nextPart.play()
.then(() => {
++this.currentIndex; // this is now the current video
if (!this.playing) { // somehow got stop in between ?
this.playing = true;
this.startLoop(); // start again
}
// try to avoid at maximum the gaps between different parts
if (this.recording && this.recorder.state === 'paused') {
this.recorder.resume();
}
});
}
startLoop() { // starts our update loop
// see https://stackoverflow.com/questions/40687010/
this.stopLoop = audioTimerLoop(e => this.update(), 1000 / FPS);
}
update(t) { // at every tick
const currentPart = this.parts[this.currentIndex];
this.updateTimeLine(); // update the timeline
if (!this.playing || currentPart.paused) { // somehow got stopped
this.playing = false;
if (this.stopLoop) {
this.stopLoop(); // stop the loop
}
}
this.draw(this.currentIndex); // draw the current video on the canvas
// calculate how long we've got until the end of this part
const remainingTime = currentPart._duration - currentPart.currentTime;
if (remainingTime < (2 / FPS)) { // less than 2 frames ?
setTimeout(e => this.startNext(), remainingTime / 2); // start the next part
}
}
draw(index) { // draw the video[index] on the canvas
this.v_ctx.drawImage(this.parts[index], 0, 0);
}
updateTimeLine() {
// get the sum of all parts' currentTime
this.currentTime = this.parts.reduce((a, b) =>
(isFinite(b.currentTime) ? b.currentTime : b._duration) + a, 0);
canvas_player_timeline.value = this.currentTime;
}
onended() { // triggered when the last part ends
// if we are recording, stop the recorder
if (this.recording && this.recorder.state !== 'inactive') {
this.recorder.stop();
}
// go back to first frame
this.resetAll();
this.currentIndex = 0;
this.playing = false;
}
onseeking(evt) { // when we click the timeline
// first reset all videos' currentTime to 0
this.parts.forEach(v => v.currentTime = 0);
this.currentTime = +evt.target.value;
let index = 0;
let sum = 0;
// find which part should be played at this time
for (index; index < this.parts.length; index++) {
let p = this.parts[index];
if (sum + p._duration > this.currentTime) {
break;
}
sum += p._duration;
p.currentTime = p._duration;
}
this.currentIndex = index;
// set the currentTime of this part
this.parts[index].currentTime = this.currentTime - sum;
if (this.playing) { // if we were playing
this.startVid(index); // set this part as the current one
} else {
this.parts[index].onseeked = e => { // wait we actually seeked the correct position
this.parts[index].onseeked = null;
this.draw(index); // and draw a single frame
};
}
}
record() { // inits the recording
this.recording = true; // let the app know we're recording
this.resetAll(); // go back to first frame
canvas_controls.classList.add('disabled'); // disable controls
const v_stream = this.canvas.captureStream(FPS); // make a stream of our canvas
const dest = this.a_ctx.createMediaStreamDestination(); // make a stream of our AudioContext
this.volume_node.connect(dest);
// FF bug... see https://bugzilla.mozilla.org/show_bug.cgi?id=1296531
let merged_stream = null;
if (!('mozCaptureStream' in HTMLVideoElement.prototype)) {
v_stream.addTrack(dest.stream.getAudioTracks()[0]);
merged_stream = v_stream;
} else {
merged_stream = new MediaStream(
v_stream.getVideoTracks().concat(dest.stream.getAudioTracks())
);
}
const chunks = [];
const rec = this.recorder = new MediaRecorder(merged_stream, {
mimeType: MediaRecorder._preferred_type
});
rec.ondataavailable = e => chunks.push(e.data);
rec.onstop = e => {
merged_stream.getTracks().forEach(track => track.stop());
this.export(new Blob(chunks));
}
rec.start();
this.startVid(0); // start playing
}
export (blob) { // once the recording is over
const a = document.createElement('a');
a.download = a.innerHTML = 'merged.webm';
a.href = URL.createObjectURL(blob, {
type: MediaRecorder._preferred_type
});
exports_cont.appendChild(a);
canvas_controls.classList.remove('disabled');
this.recording = false;
this.resetAll();
}
}
// END Player
function generateSelect(slices) { // generates a select to show each slice independently
const select = document.createElement('select');
select.appendChild(new Option('none', -1));
slices.forEach((v, i) => select.appendChild(new Option(`slice ${i}`, i)));
document.body.insertBefore(select, slice_player_cont);
select.onchange = e => {
slice_player_cont.firstElementChild && slice_player_cont.firstElementChild.remove();
if (+select.value === -1) return; // 'none'
slice_player_cont.appendChild(slices[+select.value]);
};
}
async function getSlices(url) { // loads the main video, and record some slices from it
const mainVid = await loadVid(url);
// try to make the slicing silent... That's not easy.
let a = null;
if (mainVid.mozCaptureStream) { // target FF
a = new AudioContext();
// this causes an Range error in chrome
// a.createMediaElementSource(mainVid);
} else { // chrome
// this causes the stream to be muted too in FF
mainVid.muted = true;
// mainVid.volume = 0; // same
}
mainVid.play();
const mainStream = mainVid.captureStream ? mainVid.captureStream() : mainVid.mozCaptureStream();
console.log('mainVid loaded');
const slices = await getSlicesInLoop(mainStream, mainVid);
console.log('all slices loaded');
setTimeout(() => console.clear(), 1000);
if (a && a.close) { // kill the silence audio context (FF)
a.close();
}
mainVid.pause();
URL.revokeObjectURL(mainVid.src);
return Promise.resolve(slices);
}
async function getSlicesInLoop(stream, mainVid) { // far from being precise
// to do it well, we would need to get the keyframes info, but it's out of scope for this answer
let slices = [];
const loop = async function(i) {
const slice = await mainVid.play().then(() => getNewSlice(stream, mainVid));
console.log(`${i + 1} slice(s) loaded`);
slices.push(slice);
if ((mainVid.currentTime < mainVid._duration) && (i + 1 < MAX_SLICES)) {
loop(++i);
} else done(slices);
};
loop(0);
let done;
return new Promise((res, rej) => {
done = arr => res(arr);
});
}
function getNewSlice(stream, vid) { // one recorder per slice
return new Promise((res, rej) => {
const rec = new MediaRecorder(stream, {
mimeType: MediaRecorder._preferred_type
});
const chunks = [];
rec.ondataavailable = e => chunks.push(e.data);
rec.onstop = e => {
const blob = new Blob(chunks);
res(loadVid(URL.createObjectURL(blob)));
}
rec.start();
setTimeout(() => {
const p = vid.pause();
if (p && p.then)
p.then(() => rec.stop())
else
rec.stop()
}, CHUNK_DURATION);
});
}
function loadVid(url) { // helper returning an video, preloaded
return fetch(url)
.then(r => r.blob())
.then(b => makeVid(URL.createObjectURL(b)))
};
function makeVid(url) { // helper to create a video element
const v = document.createElement('video');
v.control = true;
v.preload = 'metadata';
return new Promise((res, rej) => {
v.onloadedmetadata = e => {
// chrome duration bug...
// see https://bugs.chromium.org/p/chromium/issues/detail?id=642012
// will also occur in next FF versions, in worse...
if (v.duration === Infinity) {
v.onseeked = e => {
v._duration = v.currentTime; // FF new bug never updates duration to correct value
v.onseeked = null;
v.currentTime = 0;
res(v);
};
v.currentTime = 1e5; // big but not too big either
} else {
v._duration = v.duration;
res(v);
}
};
v.onerror = rej;
v.src = url;
});
};
function mess_up_array(arr) { // shuffles an array
const _sort = () => {
let r = Math.random() - .5;
return r < -0.1 ? -1 : r > 0.1 ? 1 : 0;
};
arr.sort(_sort)
arr.sort(_sort)
arr.sort(_sort);
}
/*
An alternative timing loop, based on AudioContext's clock
@arg callback : a callback function
with the audioContext's currentTime passed as unique argument
@arg frequency : float in ms;
@returns : a stop function
*/
function audioTimerLoop(callback, frequency) {
const freq = frequency / 1000; // AudioContext time parameters are in seconds
const aCtx = new AudioContext();
// Chrome needs our oscillator node to be attached to the destination
// So we create a silent Gain Node
const silence = aCtx.createGain();
silence.gain.value = 0;
silence.connect(aCtx.destination);
onOSCend();
var stopped = false; // A flag to know when we'll stop the loop
function onOSCend() {
const osc = aCtx.createOscillator();
osc.onended = onOSCend; // so we can loop
osc.connect(silence);
osc.start(0); // start it now
osc.stop(aCtx.currentTime + freq); // stop it next frame
callback(aCtx.currentTime); // one frame is done
if (stopped) { // user broke the loop
osc.onended = function() {
aCtx.close(); // clear the audioContext
return;
};
}
};
// return a function to stop our loop
return () => stopped = true;
}
// get the preferred codec available (vp8 is my personal, more reader support)
MediaRecorder._preferred_type = [
"video/webm\;codecs=vp8",
"video/webm\;codecs=vp9",
"video/webm\;codecs=h264",
"video/webm"
]
.filter(t => MediaRecorder.isTypeSupported(t))[0];
init();
})();#canvas_player_cont {
display: none;
position: relative;
}
#canvas_player_cont.disabled {
opacity: .7;
pointer-events: none;
}
#canvas_controls {
position: absolute;
bottom: 4px;
left: 0px;
width: calc(100% - 8px);
display: flex;
background: rgba(0, 0, 0, .7);
padding: 4px;
}
#canvas_player_play_btn {
flex-grow: 0;
}
#canvas_player_timeline {
flex-grow: 1;
}<div id="slice_player_cont">
</div>
<div id="canvas_player_cont">
<div id="canvas_controls">
<button id="canvas_player_play_btn">play</button>
<input type="range" min="0" max="10" step="0.01" id="canvas_player_timeline">
<button id="canvas_player_record_btn">save</button>
</div>
</div>
<div id="exports_cont"></div>
关于javascript - 如何使用 Blob URL、MediaSource 或其他方法播放串联的媒体片段 Blob?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/45217962/
我正在学习如何使用Nokogiri,根据这段代码我遇到了一些问题:require'rubygems'require'mechanize'post_agent=WWW::Mechanize.newpost_page=post_agent.get('http://www.vbulletin.org/forum/showthread.php?t=230708')puts"\nabsolutepathwithtbodygivesnil"putspost_page.parser.xpath('/html/body/div/div/div/div/div/table/tbody/tr/td/div
总的来说,我对ruby还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用
我有一个Ruby程序,它使用rubyzip压缩XML文件的目录树。gem。我的问题是文件开始变得很重,我想提高压缩级别,因为压缩时间不是问题。我在rubyzipdocumentation中找不到一种为创建的ZIP文件指定压缩级别的方法。有人知道如何更改此设置吗?是否有另一个允许指定压缩级别的Ruby库? 最佳答案 这是我通过查看rubyzip内部创建的代码。level=Zlib::BEST_COMPRESSIONZip::ZipOutputStream.open(zip_file)do|zip|Dir.glob("**/*")d
类classAprivatedeffooputs:fooendpublicdefbarputs:barendprivatedefzimputs:zimendprotecteddefdibputs:dibendendA的实例a=A.new测试a.foorescueputs:faila.barrescueputs:faila.zimrescueputs:faila.dibrescueputs:faila.gazrescueputs:fail测试输出failbarfailfailfail.发送测试[:foo,:bar,:zim,:dib,:gaz].each{|m|a.send(m)resc
很好奇,就使用rubyonrails自动化单元测试而言,你们正在做什么?您是否创建了一个脚本来在cron中运行rake作业并将结果邮寄给您?git中的预提交Hook?只是手动调用?我完全理解测试,但想知道在错误发生之前捕获错误的最佳实践是什么。让我们理所当然地认为测试本身是完美无缺的,并且可以正常工作。下一步是什么以确保他们在正确的时间将可能有害的结果传达给您? 最佳答案 不确定您到底想听什么,但是有几个级别的自动代码库控制:在处理某项功能时,您可以使用类似autotest的内容获得关于哪些有效,哪些无效的即时反馈。要确保您的提
假设我做了一个模块如下:m=Module.newdoclassCendend三个问题:除了对m的引用之外,还有什么方法可以访问C和m中的其他内容?我可以在创建匿名模块后为其命名吗(就像我输入“module...”一样)?如何在使用完匿名模块后将其删除,使其定义的常量不再存在? 最佳答案 三个答案:是的,使用ObjectSpace.此代码使c引用你的类(class)C不引用m:c=nilObjectSpace.each_object{|obj|c=objif(Class===objandobj.name=~/::C$/)}当然这取决于
我试图在一个项目中使用rake,如果我把所有东西都放到Rakefile中,它会很大并且很难读取/找到东西,所以我试着将每个命名空间放在lib/rake中它自己的文件中,我添加了这个到我的rake文件的顶部:Dir['#{File.dirname(__FILE__)}/lib/rake/*.rake'].map{|f|requiref}它加载文件没问题,但没有任务。我现在只有一个.rake文件作为测试,名为“servers.rake”,它看起来像这样:namespace:serverdotask:testdoputs"test"endend所以当我运行rakeserver:testid时
我正在尝试使用ruby和Savon来使用网络服务。测试服务为http://www.webservicex.net/WS/WSDetails.aspx?WSID=9&CATID=2require'rubygems'require'savon'client=Savon::Client.new"http://www.webservicex.net/stockquote.asmx?WSDL"client.get_quotedo|soap|soap.body={:symbol=>"AAPL"}end返回SOAP异常。检查soap信封,在我看来soap请求没有正确的命名空间。任何人都可以建议我
我正在尝试设置一个puppet节点,但rubygems似乎不正常。如果我通过它自己的二进制文件(/usr/lib/ruby/gems/1.8/gems/facter-1.5.8/bin/facter)在cli上运行facter,它工作正常,但如果我通过由rubygems(/usr/bin/facter)安装的二进制文件,它抛出:/usr/lib/ruby/1.8/facter/uptime.rb:11:undefinedmethod`get_uptime'forFacter::Util::Uptime:Module(NoMethodError)from/usr/lib/ruby
关闭。这个问题是opinion-based.它目前不接受答案。想要改进这个问题?更新问题,以便editingthispost可以用事实和引用来回答它.关闭4年前。Improvethisquestion我想在固定时间创建一系列低音和高音调的哔哔声。例如:在150毫秒时发出高音调的蜂鸣声在151毫秒时发出低音调的蜂鸣声200毫秒时发出低音调的蜂鸣声250毫秒的高音调蜂鸣声有没有办法在Ruby或Python中做到这一点?我真的不在乎输出编码是什么(.wav、.mp3、.ogg等等),但我确实想创建一个输出文件。