Conversation
|
这周末有事不在家,可能下周才有空看看。 另外我建议给我一个非 master 分支可编辑的 collaborator 权限, 这样我可以直接把改动推到这个分支 |
感觉先针对 audio 分支提 pr 就可以了,我直接合并后应该会自动同步到这个 pr 中。 |
|
所以为啥引入了个依赖没去用sokol_audio.h,是有啥限制吗 |
|
sokol_audio.h 只有设备驱动,相当于图形层只给了你一个 framebuffer 。你还需要实现 音频数据解码、混音… |
|
soloud 最有价值的部分是混音,其次是 filter 和 audio source ,这两个迟早也是需要的。 |
feat(build/luamake): support audio
|
我想再尝试一下集成 https://github.com/mackron/miniaudio
soloud 提供的那些更丰富的外层功能就没了。不过如果需要这些玩具(例如文字转语音),soloud 似乎也可以用 miniaudio 作 backend |
|
不管是哪个 backend 在 wasm 都一样需要在主线程创建,通过用户手势触发播放。音频线程初始化流程似乎比较麻烦。 |
miniaudio 有更 low level 的线程控制 api ,或许可以和 ltask 结合的更好一些。初始化部分已经有机制可以放去主线程运行, 但 miniaudio 没有从内存加载数据的 api ,而必须额外实现一个 VFS 。所以我得先导出一套 C 接口的 zip reader ,才能为 miniaudio 实现对应接口。 |
|
我已经更换成 miniaudio ,还有几项工作需要做:
下午带娃去攀岩,晚上继续搞。 |
|
luamake 修好了。emcc 还需要 @yuchanns 调整一下。 |
|
不在家所以远程指挥 copilot 修了一下。 你看着 cherry pick 一下。 还有个问题是 https://yuchanns.github.io/soluna/examples/audio/ 依旧会卡住,我没法看 f12 看不到有没有报错。不过我猜测是 io 卡住 具体改这两个就行 |
|
我正在尝试把 audio 初始化放在主线程。我发现最简单的方法还是一开始就初始化好,而不是惰性初始化。 初始化 audio 一定会触发权限请求吗? |
|
根据 mdn 文档看,创建 audio context 只需要在主线程即可。是播放需要权限 |
|
d04c5fa 目前采用了最简单的修改方法:在 render 初始化图形设备的同时,把声音设备也初始化。这个步骤在游戏主程序的最前面。 然后在 audio 服务被调用 init 时,去 render 服务获取 engine 对象指针就可以了。 所以 |
EM_JS(int, saudio_js_init, (int sample_rate, int num_channels, int buffer_size), {
Module._saudio_context = null;
Module._saudio_node = null;
if (typeof AudioContext !== 'undefined') {
Module._saudio_context = new AudioContext({
sampleRate: sample_rate,
latencyHint: 'interactive',
});
}
else {
Module._saudio_context = null;
console.log('sokol_audio.h: no WebAudio support');
}
if (Module._saudio_context) {
console.log('sokol_audio.h: sample rate ', Module._saudio_context.sampleRate);
Module._saudio_node = Module._saudio_context.createScriptProcessor(buffer_size, 0, num_channels);
Module._saudio_node.onaudioprocess = (event) => {
const num_frames = event.outputBuffer.length;
const ptr = __saudio_emsc_pull(num_frames);
if (ptr) {
const num_channels = event.outputBuffer.numberOfChannels;
for (let chn = 0; chn < num_channels; chn++) {
const chan = event.outputBuffer.getChannelData(chn);
for (let i = 0; i < num_frames; i++) {
chan[i] = HEAPF32[(ptr>>2) + ((num_channels*i)+chn)]
}
}
}
};
Module._saudio_node.connect(Module._saudio_context.destination);
// in some browsers, WebAudio needs to be activated on a user action
const resume_webaudio = () => {
if (Module._saudio_context) {
if (Module._saudio_context.state === 'suspended') {
Module._saudio_context.resume();
}
}
};
document.addEventListener('click', resume_webaudio, {once:true});
document.addEventListener('touchend', resume_webaudio, {once:true});
document.addEventListener('keydown', resume_webaudio, {once:true});
return 1;
}
else {
return 0;
}
})我看了下 pacman.c 的处理方案是在初始化全局监听任意用户手势触发 |
|
下周如果 mac 和 linux 都正常,我就合并到 master 。web 可以慢慢弄。 然后就可以完善 audio 的具体功能。 |
哦,不对,我才看到, 现在是调用 load_sounds 才会进行 audio.init ? 所以还是 audio.init 卡住了. 所以理下来是不是这样? audio service 的 init 在等待就绪, 然后 start.lua 也在同步等待就绪好进行 dispatch, 所以它无法响应 frame callback, 进而导致主线程等待帧响应, 所以就卡住了? 最后因为主线程卡住了, 没法让出事件循环给 audio worklet 运行, 所以 audio worklet 无法执行 callback 改变 initResult 的状态, 闭环造成 audio.init 的死锁 而现在 master 的运行逻辑时, 在主线程上进行 audio.init 时, 基于 JSPI 的 emscripten_sleep 会让出事件循环有机会进行 audio worklet 的初始化, 所以下次进来可以成功地拿到就绪状态. 这也解释了为什么在 sokol 的 init_cb 或者 frame_cb 初始化都会导致卡住, 因为已经进入到帧等待的死锁循环中 |
|
我尝试把同步等待改成异步等待,果然不会卡死了。但是发现了另一个问题: ma_engine_init 其实不仅仅是创建 context 要在主线程,它内部还有个方法 static ma_result ma_context_init__webaudio(ma_context* pContext, const ma_context_config* pConfig, ma_backend_callbacks* pCallbacks)
{
int resultFromJS;
MA_ASSERT(pContext != NULL);
(void)pConfig; /* Unused. */
/* Here is where our global JavaScript object is initialized. */
resultFromJS = EM_ASM_INT({
if (typeof window === 'undefined' || (window.AudioContext || window.webkitAudioContext) === undefined) {
return 0; /* Web Audio not supported. */
}
if (typeof(window.miniaudio) === 'undefined') {
window.miniaudio = {
referenceCount: 0
};
//...非主线程是访问不到 window 变量的, 所以这里直接就 return 0, 结果就会发生 No Backend 错误。所以把初始化分割成两部分的想法基本上应该是不可行了。 所以:
并不是这样, 只是官方的 demo 全程是在主线程运行的, 实现上本来就没有支持 pthread 的考虑. |
那应该就简单了。最开始的版本:把 audio.init 放在 render service 的初始化里,和图形的初始化部分一起在主线程构造就可以了。 之前之所以有问题,无非是:
我们前面是碰到了问题 1 ,所以才把 audio.init 移到了最前面,避开在 frame callback 里调用。 修改我放在 https://github.com/cloudwu/soluna/tree/audioinit2 这个分支上了 e03e5e3 ,如果我没理解错 https://github.com/cloudwu/soluna/blob/audioinit2/src/audio.c#L10 这个宏定义就可以解决 1 ;2 现在已经解决了。 把 audio.init 提到最前,即在启动 ltask 之前执行或许更好。但小问题是 vfs 的初始化变成两步的。把 audio engine init 分两步进行的确不好,太依赖其具体实现。 |
那么,现在的 master 分支上的版本需要开 -fwasm_exceptions 的理由是因为链接了 pthread ? 因为 master 分支上调用 audio.init 初始化 audio engine 已经完全等价于 miniaudio 官方 demo 。初始化流程运行在 ltask 初始化之前,未涉及任何 pthread 的调用。所以,光链接 pthread 就破坏了 miniaudio 官方 demo ? |
不行, 因为在主线程进行 threading sleep 会把主线程阻塞住. 就更没有机会让出事件循环了去进行 audio worklet 的初始化了。使用 emscripten_sleep 的原因就是要在主线程进行 yield 等待 |
不是。官方 demo 启动 pthread 链接也没事。我这句话的意思是,它内部实现大量依赖 window ,预设了必须跑在主线程上。对 pthread 的支持只有 resource manager 会跑在 pthread 上。 链接 wasm_exception 的原因是使用 emscripten_sleep 其实就是 async await 化,需要 suspend frame。而开启了这个 flag 就是在 wasm 内部进行 longjmp ,不开启就是用 js 胶水处理,就会导致栈帧恢复不了。我理解是这样。 |
在 sokol_main 里初始化 audio 不是和 miniaudio 官方 demo 一致吗?master 分支版本是在启动 ltask 之前调用的 audio.init(),也就没启动 pthread ,应该是官方 demo 一致才对。 我的想法是:只要 miniaudio 的官方 demo 可以正常运行,那么总存在一个方法可以和 demo 一样的执行路径把初始化工作完成;然后保留初始化好的指针,就能继续在 ltask 环境中使用。 所以要做的工作就是如何保持和 miniaudio demo 一样的环境。 |
这个方案不行还有一个原因,就算我们没有替换 sleep 实现的情况下,就是我上面分析的那样。在 sokol 的 cb 中进行 audio.init 会 yield 等待 audio worklet 启动。但是因为 yield 了没法响应 cb (此时还在主线程对吧?), sokol app 应该还在另一端 mainthread wait,从而会阻塞 sokol app。而 sokol app 没法让出主线程事件循环,就没有机会完成 audio worklet 启动,所以 audio init 没法 resume ,从而形成死锁 |
|
我觉得目前 master 的版本比较好,因为它把所有初始化工作都放在了 pthread 所有线程之前,和线程无关。我只是不理解尚存的限制是怎样引起的。 我倾向于上面这条路,下面写的仅仅是就问题讨论: “audio worklet 启动” 在主线程因为阻塞无法启动的问题我也理解了。我猜硬要解决的话,似乎也能找到 workaround: 既然我们可以把 sleep 换掉,应该也可以把启动 worklet 换掉:比如再启动一个 pthread 线程,用这个线程启动 worklet 。 |
很奇怪,这个例子可以运行: #if defined(__EMSCRIPTEN__)
#define SOKOL_GLES3
#else
#define SOKOL_METAL
#endif
#define SOKOL_IMPL
#define SOKOL_APP_IMPL
#define SOKOL_LOG_IMPL
#include "sokol_app.h"
#include "sokol_log.h"
#define MINIAUDIO_IMPLEMENTATION
#define MA_NO_DECODING
#define MA_NO_ENCODING
#include "miniaudio.h"
#include <stdbool.h>
#include <stdio.h>
#define DEVICE_FORMAT ma_format_f32
#define DEVICE_CHANNELS 2
#define DEVICE_SAMPLE_RATE 48000
#define TONE_FREQUENCY 220.0
#define TONE_AMPLITUDE 0.2
typedef struct {
ma_waveform sine_wave;
ma_device device;
bool audio_started;
bool audio_failed;
} app_state_t;
static app_state_t g_app;
static void data_callback(ma_device *pDevice, void *pOutput, const void *pInput,
ma_uint32 frameCount) {
ma_waveform_read_pcm_frames((ma_waveform *)pDevice->pUserData, pOutput,
frameCount, NULL);
(void)pInput;
}
static bool start_audio(void) {
if (g_app.audio_started) {
return true;
}
if (g_app.audio_failed) {
return false;
}
if (ma_device_start(&g_app.device) != MA_SUCCESS) {
printf("Failed to start playback device.\n");
ma_device_uninit(&g_app.device);
ma_waveform_uninit(&g_app.sine_wave);
g_app.audio_failed = true;
return false;
}
printf("Audio started.\n");
g_app.audio_started = true;
return true;
}
static void init(void) {
}
static void frame(void) {}
static void cleanup(void) {
ma_device_uninit(&g_app.device);
ma_waveform_uninit(&g_app.sine_wave);
}
static void event(const sapp_event *ev) {
switch (ev->type) {
case SAPP_EVENTTYPE_MOUSE_DOWN:
case SAPP_EVENTTYPE_TOUCHES_BEGAN:
case SAPP_EVENTTYPE_KEY_DOWN:
if (!g_app.audio_started && !g_app.audio_failed) {
start_audio();
}
break;
default:
break;
}
}
sapp_desc sokol_main(int argc, char *argv[]) {
(void)argc;
(void)argv;
g_app = (app_state_t){0};
ma_device_config device_config;
ma_waveform_config sine_wave_config;
ma_result result;
sine_wave_config = ma_waveform_config_init(
DEVICE_FORMAT, DEVICE_CHANNELS, DEVICE_SAMPLE_RATE, ma_waveform_type_sine,
TONE_AMPLITUDE, TONE_FREQUENCY);
ma_waveform_init(&sine_wave_config, &g_app.sine_wave);
device_config = ma_device_config_init(ma_device_type_playback);
device_config.playback.format = DEVICE_FORMAT;
device_config.playback.channels = DEVICE_CHANNELS;
device_config.sampleRate = DEVICE_SAMPLE_RATE;
device_config.dataCallback = data_callback;
device_config.pUserData = &g_app.sine_wave;
result = ma_device_init(NULL, &device_config, &g_app.device);
if (result != MA_SUCCESS) {
printf("Failed to open playback device.\n");
ma_waveform_uninit(&g_app.sine_wave);
g_app.audio_failed = true;
sapp_quit();
}
printf("Device Name: %s\n", g_app.device.playback.name);
printf("App initialized. Click / tap / press any key to start audio.\n");
return (sapp_desc){
.init_cb = init,
.frame_cb = frame,
.cleanup_cb = cleanup,
.event_cb = event,
.width = 640,
.height = 480,
.window_title = "sokol + miniaudio + emscripten",
.logger.func = slog_func,
};
}使用编译命令: 这个流程跟 master 应该没什么差异才对,但是 master 去掉 -fwasm_exceptions 就会报错. 另外我晚上在家里去掉 -fwasm_exceptions 这次遇到的 JS frames 报错是这个: 所以跟 master 区别是加了 Lua ? PS: 我严重怀疑昨天我看到去掉 -fwasm_exceptions 时报错的堆栈和之前一样,是网页的 cdn 缓存错误导致的匹配错误(依赖 soluna.wasm.map). 因为我今天晚上在检查中发现堆栈显示不对(跑到 stb 去了), 我又开了个无痕浏览器才看到是这个错误。然后我就回到普通浏览器模式强制刷新后,变得一致了 |
|
你这个出错信息中调试符号不对呀,文件名全乱了。这些符号名都是 lua 实现中的,但是 zip.c / datalist.c 显然是错误的。但这不重要。 以最终出错点来看, 具体见 https://github.com/lua/lua/blob/master/ldo.c#L76-L93 。不过我们的所有和 lua 对接的 C 库都没有使用 C++ ,所以并不需要用这个版本。我认为最好先确认构建 lua 时到底用的什么版本的 ps. 虽然 soluna 里包含一点 C++ 代码(yoga),但和 Lua 对接的是 C 接口。lua 提供 try/catch 方案实现 lua 的 pcall/error 的目的是用 C++ 实现 lua 库时可以在 unwind 时正确调用 C++ 对象的析构函数。 如果要检验是不是和 lua 有关,可以在上面的例子中加入 lua_State *L = luaL_newstate();
luaL_openlibs(L);
lua_close(L);看上面的调用栈,应该时发生在 luaL_openlibs 过程中,初始化 package 这个库 (luaopen_package) 。 |
|
从这里看 https://emscripten.org/docs/porting/setjmp-longjmp.html 我大略看了文档,是不是说 emscripten 模式的 longjmp 使用 js 实现的,所以存在边界问题?而使用 wasm 模式的 longjmp 则是用 wasm 内置的异常捕获机制实现的。
|
|
单纯加了 lua 并没有影响。可能是 *.map 映射错了吧。 我知道了, 是 github pages 缓存了 *.map, 无痕浏览器并不能排除这个影响。所以上面这个堆栈信息是错误的
是的。所以我之前理解是会影响到 emscripten_sleep 的 yield 和 resume |
我猜测首先的堆栈错了。能猜到的错误路径是通过 lua 调用 audio.init 这段:可能是因为 lua 在调用 audio.init() 之前需要 setjmp ,其后 audio.init 内部调用了和 worklet 相关的 api ,与之产生了冲突。 为了验证这点,可以在 https://github.com/cloudwu/soluna/blob/master/src/audio.c#L192 插入 printf 验证以下。 不过我看了一下实现,会不会是 inject_webaudio_resume/soluna_webaudio_resume_on_gesture 出的问题?而不是 ma_engine_init 本身?毕竟你在这里调用了 js 。如果我这个猜想正确,其实可以把 inject_webaudio_resume 独立出来调用,和 audio.init_vfs 一样处理。或者临时放在 init_vfs 里测试一下。 ps. 即使不是这里的问题,我也不建议在这里调用 js 。感觉这个阶段做的事情越少越好。 |
经过测试发现不需要这个也可以. miniaudio 本身就会进行注入. 移除之后确实不需要 -fwasm-exceptions 了 PS: debug 时栈信息匹配错误似乎也是因为没有开 -fwasm-exceptions PS2: safari 还是没声音, 也许有更严格的限制? PS3: 好像所有问题都是这个引起的, 现在改为 ASYNCIFY 可以运行了, 就是体积大点, 从1.17M -> 1.8M. 我在这个 issue 里看到 Safari 目前暂无意愿支持 JSPI WebKit/standards-positions#422 PS4: 看到一些针对 Safari 不支持 JSPI 的 polyfill 方案: |
从前面 longjmp 文档看,打开几乎有更好的性能、更小的代码体积?
意思是说,safari 支持 audio worklet ,但是不支持 JSPI ? |
|
safari 不支持 JSPI, 体现为访问直接报错提示 JSPI is not supported. 但是改成 ASYNCIFY 之后, 可以正常访问,只是不会出声, 所以我猜测"有更严格的限制". 关于这点我还在研究缺了什么, 它跟是 ASYNCIFY 还是 JSPI 应该没关系, 可能就是权限问题. ASYNCIFY 和 wasm-exceptions 有冲突, 不可一起编译, 因为两者都会修改控制流. 当然我觉得如果我们能解决这个 emscripten_sleep 的引入是最好的, 现在无论开启 ASYNCIFY 还是 JSPI 都是为了这盘醋. 我在想, 其实这个 while -> sleep -> check status 的行为, 完全可以有别的解法? 比如初始化后, 在 callback 里进行状态检查, 每一帧检查一次, 其实就不用 sleep 了吧? 我们现在在 callback 里会死锁是因为 sleep 挂起造成循环依赖死锁。如果不 sleep 应该就不会死锁. 这样就可以放在任意环节初始化只需要保证初始化的时候在主线程运行就行了 |
|
那么,目前 Script Node Processor 模式会不会兼容性更好呢?比如 safari 可以用? |
|
我刚才换成了 Script Node Processor, Safari 一样没有声音. 而其他浏览器声音播放不完整. |
|
不完整看起来是混音/播放的线程工作了一半没接上。不知道没声音是不是这个线程没工作起来的缘故。 |
|
我刚刚还发现两个回归问题:
在解决1的问题后, 使用 ScirptNodeProcessor, safari 也有声音了, 但是和其他浏览器一样, 声音播放不完整, 只播放了前半截. 注意: 这个 Safari 栈溢出和我多次点击音频没关系, 即使我不去点也会溢出。其他 tests 也一样会有这个溢出问题, 只要开了 ASYNCIFY 就会。我还不知道什么原因。考虑到不是立即溢出, 我怀疑是 frame callback 导致的 output.mp4如果能解决声音不完整的问题,感觉 ScriptNodeProcessor 可能是现在相对好的选择: 不需要开 Async, 没有栈溢出, 不需要 WASM_WORKERS 可以恢复 extlua 特性的支持。但是我不是很清楚在主线程又要渲染又要播放声音的情况下有什么利弊. 当然, 如果能解决我上面说的干掉 sleep 的问题, 实现 " while -> sleep -> check status 的行为转换成在 callback 里进行就绪状态检查, 每一帧检查一次", 那就可以摆脱 ASYNC 同时又享受到 Audio Worklet 的好处. 我下午让 AI 快速糊了一个验证了这想法是可行的(AI 写了个自定义 backend, 初始化的时候先使用空 device, 然后再放入自己实现的通过帧流转状态已经就绪的 device) |
|
就 ScriptNodeProcessor 模式,我简单阅读了一下代码,核心的位置在 device.scriptNode.onaudioprocess = function(e) {这行开始的 callback 函数,它注入到 js 里运行。 音频数据是通过 ma_device_handle_backend_data_callback() 函数送到设备里的。如果要调试的话,可以检查一下这个函数是不是执行了一次后就不执行了。因为它每次只传输 frameCount 个 frame 。播放一半听起来是由于什么原因它不工作了。 另外我不确定是否和在其它线程调用 play 有关。如果想检查这一点,可以在 render 线程(的 mainthread 函数内)插入一行 audio.play 试试。 |
|
在 mainthread 里调用也没什么区别. (我改成 soluna.play_sound 发送指令到 render 排队, 然后 mainthread 执行时进行播放). callback 有持续工作, 日志如下: 可以看到从第6次 callback 开始 sample 就是0了 |
|
是不是应该先用 miniaudio 的最小 demo 试试,看看对等的 log 应该是怎样的(假设这个 demo 可以正常工作)。 然后再对比放在 soluna 里的差别。 看起来 ScriptNodeProcessor 模式是最简单的,不涉及线程。它就是浏览器框架驱动主线程中的 onaudioprocess 回调,周期性填充数据。而这些数据则是由 ma_device_handle_backend_data_callback 填充。这个流程应当比较容易调试清楚。我感觉也不太可能是多线程导致的,ScriptNodeProcessor 模式似乎不需要任何线程工作。 如果是非双工模式,传输数据是由 ma_device__read_frames_from_client 完成,双工则是 ma_device__handle_duplex_callback_playback 。这两个函数都是和 backend 无关。 如果 miniaudio demo 和 soluna 都是启动播放相同的 wav 的话,ma_device_handle_backend_data_callback 里看到的数据以及参数应该完全一致才对。 另一个猜想:如果刚好播放了一半,会不会和声道的处理有关系?这里设备看起来是两个声道,由于什么地方参数搞错了,填了两倍的数据,结果只有一半是有效的,结果只听到了一半。要验证的话,可以弄个长一点的 wav 播放,看是不是也是刚好丢了一半? |
|
我找到原因了, 是因为自定义 resource_manager 导致的. 在 {
if (pEngine->pResourceManager == NULL) {
// ...
resourceManagerConfig = ma_resource_manager_config_init();
resourceManagerConfig.pLog = pEngine->pLog; /* Always use the engine's log for internally-managed resource managers. */
resourceManagerConfig.decodedFormat = ma_format_f32;
resourceManagerConfig.decodedChannels = 0; /* Leave the decoded channel count as 0 so we can get good spatialization. */
resourceManagerConfig.decodedSampleRate = ma_engine_get_sample_rate(pEngine);
// ...
}我在 不过我还是不明白为啥走 audio_worklet 就不会. |
|
看起来是 |
我没有硬编码。直接参考内部的做法了。详情见 pr. 这是一个时序问题. 简单来说, 如果通过外部传入 ResourceManager, 就需要自己负责配置好这些 config 参数, miniaudio 不会管. |

初步集成 soloud 用于声音播放。
soloud 功能非常齐全,但我不想一次导入所有的功能,考虑:
待非 windows 版本测试通过后,计划马上跟进的功能有:
可以考虑以后集成的特性:
目前亟待完成的工作,麻烦 @yuchanns 看看:
注:虽然 soloud 默认支持 mp3, flac ,但我觉得暂时意义不大。非压缩的 wav 和有损压缩的 ogg 已经基本能满足一般需求。所以我专门写了 https://github.com/cloudwu/soluna/blob/audio/src/soloudwavonly.h 用于剥离 mp3 和 flac 。这也大约可以让引擎编译后少 200Kb 左右的体积。
另外,soloud 的 C api 从官方版本做了裁减,可能日后根据需要再增加。(官方版本导出了所有 C++ API)
为了方便写 makefile ,我采用了和 yoga 集成时相同的方案:使用单一源文件 https://github.com/cloudwu/soluna/blob/audio/src/soloudone.cpp 编译。如果在做 luamake 脚本时有问题,可以继续讨论如何处理构建文件。
目前在 audio 分支上,有 test/audio.lua 可供测试。它启动后,会播放一个音频文件。该音频文件从 https://www.wavsource.com/ 下载。看起来没有版权问题。