Skip to content

soluna.audio#85

Merged
cloudwu merged 12 commits intomasterfrom
audio
Mar 23, 2026
Merged

soluna.audio#85
cloudwu merged 12 commits intomasterfrom
audio

Conversation

@cloudwu
Copy link
Owner

@cloudwu cloudwu commented Mar 20, 2026

初步集成 soloud 用于声音播放。

soloud 功能非常齐全,但我不想一次导入所有的功能,考虑:

  1. 先完善多平台支持。一个简单初步的版本可以让多平台版本更好启动。
  2. 根据实际游戏的需求慢慢增加功能,在实际使用中完善 api 设计更好。

待非 windows 版本测试通过后,计划马上跟进的功能有:

  1. 流式音频支持,用于播放背景音乐
  2. 音量等参数设置
  3. 允许对播放中的声音做功能控制

可以考虑以后集成的特性:

  1. 文本转语音(这可以在不提供 wav 文件时也方便测试)
  2. 其它 Audio source
  3. 声音过滤器,淡出效果等
  4. 可选的 mp3 flac 格式支持
  5. 声音文件的内存管理(目前是启动时全部加载)

目前亟待完成的工作,麻烦 @yuchanns 看看:

  1. luamake 脚本
  2. 非 windows 平台,尤其是 web 的 backend

注:虽然 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/ 下载。看起来没有版权问题。

@yuchanns
Copy link
Contributor

yuchanns commented Mar 20, 2026

这周末有事不在家,可能下周才有空看看。

另外我建议给我一个非 master 分支可编辑的 collaborator 权限, 这样我可以直接把改动推到这个分支

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 20, 2026

另外我建议给我一个非 master 分支可编辑的 collaborator 权限, 这样我可以直接把改动推到这个分支

感觉先针对 audio 分支提 pr 就可以了,我直接合并后应该会自动同步到这个 pr 中。

@cloudwu cloudwu marked this pull request as draft March 20, 2026 11:28
@samoyedsun
Copy link
Contributor

所以为啥引入了个依赖没去用sokol_audio.h,是有啥限制吗

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 20, 2026

sokol_audio.h 只有设备驱动,相当于图形层只给了你一个 framebuffer 。你还需要实现 音频数据解码、混音…

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 20, 2026

soloud 最有价值的部分是混音,其次是 filter 和 audio source ,这两个迟早也是需要的。

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 20, 2026

我想再尝试一下集成 https://github.com/mackron/miniaudio

  1. 看起来有原生的 webaudio backend
  2. 暴露了 low level api 可以控制的流程更细(但封装会更麻烦)
  3. 似乎比 soloud 项目更活跃

soloud 提供的那些更丰富的外层功能就没了。不过如果需要这些玩具(例如文字转语音),soloud 似乎也可以用 miniaudio 作 backend

@yuchanns
Copy link
Contributor

不管是哪个 backend 在 wasm 都一样需要在主线程创建,通过用户手势触发播放。音频线程初始化流程似乎比较麻烦。

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 21, 2026

不管是哪个 backend 在 wasm 都一样需要在主线程创建,通过用户手势触发播放。音频线程初始化流程似乎比较麻烦。

miniaudio 有更 low level 的线程控制 api ,或许可以和 ltask 结合的更好一些。初始化部分已经有机制可以放去主线程运行,不过可能需要额外给一个配置项在不播放声音的时候不初始化可能会友好一点? 实现为第一次播放声音时惰性初始化即可。

但 miniaudio 没有从内存加载数据的 api ,而必须额外实现一个 VFS 。所以我得先导出一套 C 接口的 zip reader ,才能为 miniaudio 实现对应接口。

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 21, 2026

我已经更换成 miniaudio ,还有几项工作需要做:

  1. 删除 soloud 这个 submodule
  2. 修改 luamake (麻烦 @yuchanns )
  3. 把初始化部分移到主线程,测试 web 版本
  4. 进一步完善 lua api 。改为用 ma_sound 对象,而不是直接用 engine_play ,加入异步加载

下午带娃去攀岩,晚上继续搞。

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 21, 2026

luamake 修好了。emcc 还需要 @yuchanns 调整一下。

3rd/miniaudio/miniaudio.h:41546:12: error: EM_ASM does not work in -std=c* modes, use -std=gnu* modes instead

@yuchanns
Copy link
Contributor

yuchanns commented Mar 21, 2026

yuchanns#2

不在家所以远程指挥 copilot 修了一下。 你看着 cherry pick 一下。

还有个问题是 https://yuchanns.github.io/soluna/examples/audio/ 依旧会卡住,我没法看 f12 看不到有没有报错。不过我猜测是 io 卡住

具体改这两个就行

From c42545ee45b8be812af7b27779cd10e2f4c88c5f Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 12:41:31 +0000
Subject: [PATCH] fix(build/wasm): use gnu11 instead of c11 for emcc to allow
 EM_ASM in miniaudio

Co-authored-by: yuchanns <25029451+yuchanns@users.noreply.github.com>
Agent-Logs-Url: https://github.com/yuchanns/soluna/sessions/8347fc71-4723-416e-8701-bb09cd07f3a0
---
 make.lua | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/make.lua b/make.lua
index bc23894..84d97c8 100644
--- a/make.lua
+++ b/make.lua
@@ -81,7 +81,7 @@ lm:conf {
 		},
 	},
 	emcc = {
-		c = "c11",
+		c = "gnu11",
 		flags = {
 			"-Wall",
 			"-pthread",

From d698c930ccd4c39c8c1539afec3ccfecd2e96c0e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 21 Mar 2026 12:21:12 +0000
Subject: [PATCH] fix(build/luamake): remove soloud, use miniaudio

---
 .gitmodules           |  3 ---
 3rd/soloud            |  1 -
 clibs/soloud/make.lua | 28 ----------------------------
 clibs/soluna/make.lua |  2 +-
 make.lua              |  2 --
 5 files changed, 1 insertion(+), 35 deletions(-)
 delete mode 160000 3rd/soloud
 delete mode 100644 clibs/soloud/make.lua

diff --git a/.gitmodules b/.gitmodules
index 61cfe0a..2878dde 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -22,9 +22,6 @@
 [submodule "bin/sokol-tools-bin"]
 	path = bin/sokol-tools-bin
 	url = https://github.com/floooh/sokol-tools-bin.git
-[submodule "3rd/soloud"]
-	path = 3rd/soloud
-	url = https://github.com/jarikomppa/soloud.git
 [submodule "3rd/miniaudio"]
 	path = 3rd/miniaudio
 	url = https://github.com/mackron/miniaudio.git
diff --git a/3rd/soloud b/3rd/soloud
deleted file mode 160000
index e82fd32..0000000
--- a/3rd/soloud
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit e82fd32c1f62183922f08c14c814a02b58db1873
diff --git a/clibs/soloud/make.lua b/clibs/soloud/make.lua
deleted file mode 100644
index 8516f88..0000000
--- a/clibs/soloud/make.lua
+++ /dev/null
@@ -1,28 +0,0 @@
-local lm = require "luamake"
-
-lm.rootdir = lm.basedir .. "/3rd/soloud"
-
-lm:source_set "soloud_src" {
-	sources = {
-		lm.basedir .. "/src/soloudone.cpp",
-	},
-	includes = {
-		"include",
-		"src",
-	},
-	windows = {
-		defines = {
-			"WITH_WINMM=1",
-		},
-	},
-	macos = {
-		defines = {
-			"WITH_COREAUDIO=1",
-		},
-	},
-	linux = {
-		defines = {
-			lm.platform == "emcc" and "WITH_SDL2_STATIC=1" or "WITH_ALSA=1",
-		},
-	},
-}
diff --git a/clibs/soluna/make.lua b/clibs/soluna/make.lua
index 1e3397d..bef6293 100644
--- a/clibs/soluna/make.lua
+++ b/clibs/soluna/make.lua
@@ -43,7 +43,7 @@ lm:source_set "soluna_src" {
 		"3rd/lua",
 		"3rd/yoga",
 		"3rd/zlib",
-		"3rd/soloud/include",
+		"3rd/miniaudio",
 	},
 	clang = {
 		sources = lm.os == "macos" and {
diff --git a/make.lua b/make.lua
index 05e81a1..bc23894 100644
--- a/make.lua
+++ b/make.lua
@@ -87,7 +87,6 @@ lm:conf {
 			"-pthread",
 			"-fPIC",
 			"--use-port=emdawnwebgpu",
-			"-s USE_SDL=2",
 		},
 		links = {
 			"idbfs.js",
@@ -100,7 +99,6 @@ lm:conf {
 			"-s FORCE_FILESYSTEM=1",
 			'-s EXPORTED_RUNTIME_METHODS=\'["FS","FS_createPath","FS_createDataFile","IDBFS"]\'',
 			"-s USE_PTHREADS=1",
-			"-s USE_SDL=2",
 			"-s PTHREAD_POOL_SIZE='Math.max(2,navigator.hardwareConcurrency)'",
 			"-s PTHREAD_POOL_SIZE_STRICT=2",
 			lm.mode == "debug" and "-s ASSERTIONS=2",

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 21, 2026

我正在尝试把 audio 初始化放在主线程。我发现最简单的方法还是一开始就初始化好,而不是惰性初始化。

初始化 audio 一定会触发权限请求吗?

@yuchanns
Copy link
Contributor

根据 mdn 文档看,创建 audio context 只需要在主线程即可。是播放需要权限

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 21, 2026

d04c5fa 目前采用了最简单的修改方法:在 render 初始化图形设备的同时,把声音设备也初始化。这个步骤在游戏主程序的最前面。

然后在 audio 服务被调用 init 时,去 render 服务获取 engine 对象指针就可以了。

所以 ma_engine 对象现在肯定时在主线程初始化的,就看播放会不会卡住了。

@yuchanns
Copy link
Contributor

yuchanns commented Mar 21, 2026

我刚才构建了我的 pages,加载正常,只是没有声音播放,也没有报错。应该就是权限问题,其他的都正常。我下周再看看

image

@yuchanns
Copy link
Contributor

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 的处理方案是在初始化全局监听任意用户手势触发

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 21, 2026

下周如果 mac 和 linux 都正常,我就合并到 master 。web 可以慢慢弄。

然后就可以完善 audio 的具体功能。

@yuchanns
Copy link
Contributor

yuchanns commented Mar 25, 2026

  emscripten_create_audio_context__deps: ['$emscriptenRegisterAudioObject', '$emscriptenGetAudioObject'],
  emscripten_create_audio_context: (options) => {
    // Safari added unprefixed AudioContext support in Safari 14.5 on iOS: https://caniuse.com/audio-api
#if MIN_SAFARI_VERSION < 140500 || ENVIRONMENT_MAY_BE_NODE || ENVIRONMENT_MAY_BE_SHELL
    var ctx = window.AudioContext || window.webkitAudioContext;

~~ 看起来我们可以自己注册到 window.AudioContext, 这样这个方法就会返回 ~~

不行, 这两个方法是互斥的

// Call this function from JavaScript to register a Wasm-side handle to an AudioContext that
// you have already created manually without calling emscripten_create_audio_context().
// Note: To let that AudioContext be garbage collected later, call the function
// emscriptenDestroyAudioContext() to unbind it from Wasm.
$emscriptenRegisterAudioObject__deps: ['$emAudio', '$emAudioCounter'],
$emscriptenRegisterAudioObject: (object) => {

@yuchanns
Copy link
Contributor

yuchanns commented Mar 25, 2026

如果要 workaround 的话,main.lua 的 function api.init(desc) 里或许是一个可调用 audio.init 的位置。https://github.com/cloudwu/soluna/blob/master/src/lualib/main.lua#L207

这个函数是在 entry.c 的 sokol_main() 里调用 init_settings() 时执行的,还没有进入 callback 。

如果这个位置调用 audio.init 是可行的,那么就可以在此处构造 audio context ,然后通过 soluna_app 这个模块传下去。类似窗口的宽高属性那样,变成 ltask 启动 start 服务的一个参数。

我尝试在 api.init 里进行初始化,结果会造成主线程空转. 永远无法到下一步,猜测是一直出于 MA_BUSY 状态.

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 25, 2026

我尝试在 api.init 里进行初始化,结果会造成主线程空转. 永远无法到下一步,猜测是一直出于 MA_BUSY 状态.

我想,这里的初始化流程还是期待在整个环境都启动起来之后,所有的 worker 都可以工作。而这里的主线程中调用这段代码, emscripten_start_wasm_audio_worklet_thread_async 并没有真的让 workelet_thread 工作起来。它只是 start 了 worklet thread ,而 thead 的工作还是得等到消息循环。

如果以上猜测成立,我不知道怎样的环境这个函数才能正确工作:即保证前面的 emscripten_create_audio_context 在主线程,又可以在 emscripten_start_wasm_audio_worklet_thread_async 之后可以处理主线程的消息循环?

有直接用 C 代码成功初始化 mini audio 的例子吗?我们只需要模仿这样的例子梳理流程也能正确初始化。


另外,我想了一个 workaround :

既然 emscripten_create_audio_context 必须在主线程调用,那我们就让它在主线程调,比如在上面提到的 api.init() 。这就相当于你说的预先 “注册到 window.AudioContext” ,但不走常规路径。

可以在集成 miniaudio.h 的地方, #include "miniaudio.h" 之前用 C 的宏定义把 emscripten_create_audio_context 换为一个 static 函数取之前真正创建 audio context 获得的指针。如果这么干,audio.init 也就不再需要在主线程调用了。

因为它并没有真的调用 emscripten_create_audio_context ,而下面这段启动额外线程并 spin 等待结果是可以在独立线程运行的

pDevice->webaudio.initResult = MA_BUSY;
{
  emscripten_start_wasm_audio_worklet_thread_async(pDevice->webaudio.audioContext, pStackBuffer, MA_AUDIO_WORKLETS_THREAD_STACK_SIZE, ma_audio_worklet_thread_initialized__webaudio, pInitParameters);
}
while (pDevice->webaudio.initResult == MA_BUSY) { emscripten_sleep(1); }

即:

  1. 在主线程 sokol_main() 里调用真正的 emscripten_create_audio_context 获得设备指针
  2. 在 audio service 里调用 audio.init() 它调用假的 emscripten_create_audio_context ,实际是前面在主线程真调用的结果。

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 25, 2026

我尝试在 api.init 里进行初始化,结果会造成主线程空转. 永远无法到下一步,猜测是一直出于 MA_BUSY 状态.

我能想到的可能性是:audio init 需要在主线程运行,而不是 callback 中(这一点 sokol_main() 符合要求)

但是,它同时还需要在 emscripten_set_main_loop 或 emscripten_request_animation_frame_loop 之后?即,所有的 callback 都注册完毕?

如果需要满足以上两点,就不可以直接使用 sokol_main ,因为 sokol 默认的封装是这样的:

int main(int argc, char* argv[]) {
    sapp_desc desc = sokol_main(argc, argv);
    _sapp_emsc_run(&desc);
    return 0;
}

设置回调函数是通过 _sapp_emsc_run 在 sokol_main 之后发生的。

但是,sokol 还是留了后门。可以定义 SOKOL_NO_ENTRY ,然后自定义一个 main 函数,主动调用 SOKOL_API_IMPL void sapp_run(const sapp_desc* desc) 代替默认的 main 。这样,我们就可以不在 sokol_main 里初始化 mini audio ,而改在 sapp_run() 之后再调用 mini audio 的初始化。

只不过,如果以上猜想若成立的话,mini audio 的初始化流程就对使用过于苛刻了。

@yuchanns
Copy link
Contributor

yuchanns commented Mar 25, 2026

miniaudio 自带例子: https://github.com/mackron/miniaudio/blob/master/examples/simple_playback_sine.c

从这个代码上看的话, 所有初始化是在 emscripten_set_main_loop 之前进行的

我晚上到家试试.

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 25, 2026

这个例子调用 ma_device_init 的时机和上面在 sokol_main 里面调用一模一样。所以问题和猜想的不一样。

@yuchanns
Copy link
Contributor

yuchanns commented Mar 25, 2026

我写了个简单 demo:

#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) {
  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");
}

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;

  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,
  };
}

然后使用 emcc main.c -o index.html -s JSPI -s AUDIO_WORKLET=1 -s WASM_WORKERS=1 -s ALLOW_MEMORY_GROWTH=1 -sUSE_PTHREADS=1
可以正常运行。

然后移植到 soluna : yuchanns@50cf896

还是会出错,而且这个调用栈有点诡异:

miniaudio.h:42076  Uncaught SuspendError: trying to suspend without WebAssembly.promising
    at soluna.wasm.ma_device_init__webaudio (miniaudio.h:42076:59)
    at soluna.wasm.ma_device_init (miniaudio.h:43768:14)
    at soluna.wasm.ma_device_init_ex (miniaudio.h:44086:22)
    at soluna.wasm.ma_device_init (miniaudio.h:43614:16)
    at soluna.wasm.app_init (entry.c:762:12)
    at soluna.wasm._sapp_call_init (sokol_app.h:3478:9)
    at soluna.wasm._sapp_frame (sokol_app.h:3699:9)
    at soluna.wasm._sapp_wgpu_frame (sokol_app.h:4291:9)
    at soluna.wasm._sapp_emsc_frame_animation_loop (sokol_app.h:8032:9)
    at tick (soluna.js:6541:30)

~~怎么是先 frame 然后才 app_init ~~

和这个没关系, 我对上面那个正常例子进行 assert 发现是一样的调用栈:

__trap.c:2  Uncaught RuntimeError: unreachable
    at index.wasm.__trap (__trap.c:2:3)
    at wrapper (index.js:3936:32)
    at abort (index.js:1097:5)
    at ___assert_fail (index.js:2054:59)
    at index.wasm.init (main.c:92:3)
    at index.wasm._sapp_call_init (sokol_app.h:3478:9)
    at index.wasm._sapp_frame (sokol_app.h:3699:9)
    at index.wasm._sapp_emsc_frame_animation_loop (sokol_app.h:8034:9)
    at tick (index.js:2594:30)

@yuchanns
Copy link
Contributor

yuchanns commented Mar 25, 2026

我发现上面这个例子其实也有问题, 只是我忘了添加 -DMA_ENABLE_AUDIO_WORKLETS, 所以之前其实没走 audio_worklet

换句话说:
使用编译参数: emcc simple_playback_sine.c -o index.html -DMA_ENABLE_AUDIO_WORKLETS -s ASSERTIONS=2 -s JSPI -s AUDIO_WORKLET=1 -s WASM_WORKERS=1 -s ALLOW_MEMORY_GROWTH=1 -sUSE_PTHREADS=1 -s EXCEPTION_STACK_TRACES=1 -fwasm-exceptions -gsource-map

  1. 官方的例子: https://github.com/mackron/miniaudio/blob/master/examples/simple_playback_sine.c, 可以运行
  2. 结合 sokol, 抛出异常 Uncaught SuspendError: trying to suspend without WebAssembly.promising
  3. soluna 自身, 和2一样的异常

所以应该就是和 sokol 的运行时有关系,

    at index.wasm.init (main.c:92:3)
    at index.wasm._sapp_call_init (sokol_app.h:3478:9)
    at index.wasm._sapp_frame (sokol_app.h:3699:9)
    at index.wasm._sapp_emsc_frame_animation_loop (sokol_app.h:8034:9)
    at tick (index.js:2594:30)

@yuchanns
Copy link
Contributor

yuchanns commented Mar 25, 2026

我把 #88 patch 我的分支好像有效果。我再调试下试试……

image

apply 这个 patch 后可以运行了。但是……依旧没声音,明天再看看

patch 之后的这段代码是有效果的,会发出声音: yuchanns@50cf896

但是 soluna 本身的播放还是没有声音。我怀疑要么是手势问题要么是 device 没有在手势里 ma_device_start

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 25, 2026

你写的 sokol 的例子和 miniaudio 官方的例子是不一样的。

sokol 的例子把初始化放在了 callback 里,肯定会触发问题。
官方的例子是在 main 里面,并在 emscripten_set_main_loop 之前。

如果要做到等价,应该写在 sokol_main 里,而不是写在被注册到 init callback 的 init 函数里。这样应该是不需要那个 patch 的。

sokol init callback 实质上就是单次触发的 frame callback 。

我的问题是这个:

我尝试在 api.init 里进行初始化,结果会造成主线程空转. 永远无法到下一步,猜测是一直出于 MA_BUSY 状态.

api.init 是由 https://github.com/cloudwu/soluna/blob/master/src/entry.c#L904 触发的,即在 sokol_main 里。看起来没有 trying to suspend without WebAssembly.promising ,却让 主线程空转. 永远无法到下一步。但官方例子并没有这个问题?

@yuchanns
Copy link
Contributor

你写的 sokol 的例子和 miniaudio 官方的例子是不一样的。

你说的对, 我改成在 sokol_main 里初始化, 是可以正常工作的

@yuchanns
Copy link
Contributor

yuchanns commented Mar 26, 2026

api.init 是由 https://github.com/cloudwu/soluna/blob/master/src/entry.c#L904 触发的,即在 sokol_main 里。看起来没有 trying to suspend without WebAssembly.promising ,却让 主线程空转. 永远无法到下一步。但官方例子并没有这个问题?

这个问题可以跳过了,我今天 revert 这段代码,又可以运行了,不知道为什么,但是确实可以了。所以可以按照你说的, 在 init_setting 里初始化, 然后将参数传递给 start

整个链路应该没问题了。顺利的话我下午提交 pr

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 26, 2026

api.init 是由 https://github.com/cloudwu/soluna/blob/master/src/entry.c#L904 触发的,即在 sokol_main 里。看起来没有 trying to suspend without WebAssembly.promising ,却让 主线程空转. 永远无法到下一步。但官方例子并没有这个问题?

这个问题可以跳过了,我今天 revert 这段代码,又可以运行了,不知道为什么,但是确实可以了。所以可以按照你说的, 在 init_setting 里初始化, 然后将参数传递给 start

整个链路应该没问题了。顺利的话我下午提交 pr

我想好了方案现在改。我觉得 web 版没声音可能是因为我前面偷懒没有把读 zip 的 vfs 初始化(默认实现没有传入 zipfile list)。

不过改在 init_setting 里面初始化,又不太好先初始化这个。所以我还需要改成两步初始化:先把声音设备创建好,后面再初始化 vfs 。

@yuchanns
Copy link
Contributor

应该是你说的这个问题了。我注意到 ma_engine_play_sound 的返回值没有被处理, 我添加了错误处理可以看到播放是失败了:

[2026-03-26 11:25:19.53][ERROR]( audio ) ma_engine_play_sound(asset/sounds/bloop_x.wav) error : Unknown error
stack traceback:
	( service:5 )
	src/service/audio.lua:16: in local 's'

cloudwu added a commit that referenced this pull request Mar 26, 2026
@cloudwu
Copy link
Owner Author

cloudwu commented Mar 26, 2026

a983873 把 audio device 的初始化移到了最前面。

增加了 audio.init_vfs 用于注入 vfs 接口支持 zipfiles 。如果不调用 init_vfs 就使用 local files 版的 vfs 。查看过 miniaudio 的实现,它只保存了 VFS 的指针,所以这里的动态替换 vfs 是可行的。

audio service 可以多次获取 sound sample 列表,这可以允许多个不同服务调用各自的 soluna.play_sound 都能工作。

@cloudwu cloudwu deleted the audio branch March 26, 2026 04:03
@yuchanns
Copy link
Contributor

yuchanns commented Mar 26, 2026

#89

  1. 移除了 extlua 在 wasm 上的支持. 暂时还没想到什么好的解决方法
  2. 修改了下 ci 可以在部署网页版时选择是否开启 debug
  3. wasm 版通过使用 EM_JS 宏注入一段用户手势事件监听来解锁音频播放权限 (想到之前有一些依靠用户授权的功能似乎也可以改成这样)
  4. 修改了 test/audio.lua 改成点击触发, 考虑到 web 需要用户手势触发
  5. 弄清楚了 我今天 revert 这段代码,又可以运行了,不知道为什么 的原因: 我在 debug 模式下使用 -fwasm-exceptions 这个 flag (https://emscripten.org/docs/porting/exceptions.html#webassembly-exception-handling-based-support) 。开启之后错误处理会在 wasm 内进行,不开启则会在 js 胶水代码里进行。好处是体积更小,性能更好,坏处是支持的浏览器(wasm 运行时)变少。比如 Safari 好像就不支持 现在我们必须开启, 如果不开启, 即使在 sokol_main 里初始化 audio 也会引发 trying to suspend without WebAssembly.promising . 这个应该和 lua 的 yield resume 一样, JSPI 会启用 async await, 所以受到影响
  6. 移除 extlua 支持后, 不需要启用 main module 这些特性, wasm 产物的体积小了很多(2.72M->1.17M), js 胶水产物也笑了很多(727K->212K)

如果考虑最大兼容性,注入用户手势监听授权后,保持现在 master 里的 Script Node Processor 主线程播放的方式应该也可以正常工作,并且可以支持 Safari, 还有 extlua 特性。

当然也许也可以试试 apply 前面 copilot 给的 patch, 然后在 init cb 里进行初始化, 这样也许不需要开启 -fwasm-exceptions 也可以支持 safari?

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 26, 2026

当然也许也可以试试 apply 前面 copilot 给的 patch, 然后在 init cb 里进行初始化, 这样也许不需要开启 -fwasm-exceptions 也可以支持 safari?

我晚上开个分支试试前面提到的另一个 workaround 。这里的初始化有两个约束:

  1. emscripten_create_audio_context 需要在主线程运行。
  2. ma_audio_worklet_thread_initialized__webaudio 的结果使用了 while (pDevice->webaudio.initResult == MA_BUSY) { emscripten_sleep(1); } 等待完成,这导致在主线程的 callback 函数中死锁。

这两个约束相互制约才造成了困境。我试试把两者拆分开来:

  1. 在主线程(也就是目前的位置)调用 emscripten_create_audio_context (用一点宏技巧)
  2. 在 audio service ,也就是一个工作线程调用 emscripten_sleep

我猜想这样就不再需要开启 -fwasm-exceptions 了。

cloudwu added a commit that referenced this pull request Mar 26, 2026
cloudwu added a commit that referenced this pull request Mar 26, 2026
@cloudwu
Copy link
Owner Author

cloudwu commented Mar 26, 2026

@yuchanns 最新的 audioinit 分支,我尝试在 emcc 版中两步初始化 audio device 。方法是:

  1. 在最开始先调用 audio.create_context() 创建 audio context ,它是一个 int handle ,保存在 C 的 static 变量中。
  2. 在 audio service 里调用 audio.init() 时,使用 C 中先初始化好的 audio context 。

这样做是建立在猜想:只有 emscripten_create_audio_context 需要在主线程运行,而其余步骤在其它线程运行更好。因为独立线程不受主线程 callback 函数的限制。

把 miniaudio 的初始化流程分拆为两步的 trick 是把 emscripten_create_audio_context 通过宏转换为 emscripten_create_audio_context_ ,让 miniaudio 的代码调用获取之前创建的 audio context 。如果这个方法测试通过,后续可能还需要进一步创建支持 context 的用户配置项(通过 settings )。暂时传入的空指针使用默认项。

@yuchanns
Copy link
Contributor

我等下部署试试

@yuchanns
Copy link
Contributor

yuchanns commented Mar 26, 2026

在修复了一点编译错误后我进行部署,结果是不行。不开 -fwasm_exceptions 直接报 js frame 的错, 开了变成在 audio service 里空转等待 可以到 yuchanns 体验

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 26, 2026

在修复了一点编译错误后我进行部署,结果是不行。不开 -fwasm_exceptions 直接报 js frame 的错, 开了变成在 audio service 里空转等待 可以到 yuchanns 体验

看来这个方案不行。只解决了第一个问题:emscripten_create_audio_context 需要在主线程运行;没有解决第二个问题:剩下的部分正确调度。

但我比较奇怪,如果和之前一样的话,那么问题 ”js frame 错误" 卡在 emscripten_sleep 上。但当我把调用放在 audio service 的话,看起来是在一个独立线程里面。那么 emscripten_sleep 对独立线程也又限制吗?如果是,只能说明 emscripten_sleep 和 pthread 不兼容,否则该限制有点大。

回到 master 分支之前开 -fwasm_exceptions 正确的版本的话(即整个 miniaudio 初始化都在 ltask 启动之前,尚未创建任何 pthread 线程),还有一点没弄明白:看起来即使在 main() 里调用 emscripten_sleep 也有限制?这有点奇怪,因为调用 emscripten_sleep 的时间点,只用 emscripten_start_wasm_audio_worklet_thread_async 启动了一个 worklet,并不设计任何 pthread 线程。这时不给调用 emscripten_sleep 的?也就是说,即使是 miniaudio 官方的 demo 其实也是不开 -fwasm_exceptions 就跑不起来?还是说,miniaudio 这里的实现本身不规范?

@yuchanns
Copy link
Contributor

yuchanns commented Mar 26, 2026

根据文档说明 em sleep 其实是给 wasm worker 用的。另外本身在 main 中是可以使用 em sleep 的。其实这里的问题不是不能 sleep ,应该是等不到 worklet 启动,因此一直在空转等 ready。
pthread 应该是预创建的,不是运行时启动的。

@cloudwu
Copy link
Owner Author

cloudwu commented Mar 26, 2026

根据文档说明 em sleep 其实是给 wasm worker 用的。另外本身在 main 中是可以使用 em sleep 的。其实这里的问题不是不能 sleep ,应该是等不到 worklet 启动,因此一直在空转等 ready。 pthread 应该是预创建的,不是运行时启动的。

所以,看起来解决方法很简单:用 #define 把 emscripten_sleep 换成 pthread 的 sleep 就可以了 :) 这样就可以在 pthread 线程里切出去等待。

如果可行,那么最早的版本,利用 ltask 的 mainthread api 就也是可行的了。

@yuchanns
Copy link
Contributor

这想法有点意思,我明天试试 :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants