Revision control
++ // Copyright (C) 2026 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR LGPL-3.0-only OR GPL-2.0-only OR GPL-3.0-only
++
++ #include "qwasmwebaudiosource_p.h"
++
++ #include <emscripten.h>
++ #include <emscripten/val.h>
++ #include <QDebug>
++ #include <QtMath>
++ #include <QIODevice>
++
++ using emscripten::EM_VAL;
++
++ QT_BEGIN_NAMESPACE
++
++ constexpr unsigned int DEFAULT_BUFFER_DURATION = 250'000; // µs
++
++ int QWasmAudioSource::s_nextId = 0;
++
++ static QHash<int, QWasmAudioSource *> s_registry;
++
++ void QWasmAudioSource::workletReadyCallback(int callbackId)
++ {
++ auto *src = s_registry.value(callbackId);
++ if (!src)
++ return;
++ src->m_workletReady = true;
++ src->connectMediaStreamIfReady();
++ }
++
++ void QWasmAudioSource::audioDataCallback(int callbackId)
++ {
++ if (auto *src = s_registry.value(callbackId))
++ src->deliverBufferedData();
++ }
++
++ extern "C" {
++ EMSCRIPTEN_KEEPALIVE void qt_onWorkletReady(int id) { QWasmAudioSource::workletReadyCallback(id); }
++ EMSCRIPTEN_KEEPALIVE void qt_onAudioFrameReady(int id) { QWasmAudioSource::audioDataCallback(id); }
++ }
++
++ #if QT_CONFIG(thread)
++
++ constexpr int RING_BUFFER_DURATION = 100'000; // µs
++
++ // Load the AudioWorklet processor via Blob URL.
++ // The processor writes PCM directly into the WASM heap (SharedArrayBuffer)
++ // ring buffer.
++ // Pointers are byte offsets into the WASM linear memory (which is a
++ // SharedArrayBuffer in threaded builds). The write position is updated with
++ // Atomics.store so the C++ acquire-load on m_writePos sees coherent data.
++
++ EM_JS(void, qt_loadWorkletModule,
++ (EM_VAL ctxHandle,
++ int ringPtr, int ringSize,
++ int wposPtr, int volPtr,
++ int channels, int fmt, int bps,
++ int callbackId), {
++ if (!Module._qtWorkletParams) Module._qtWorkletParams = {};
++ Module._qtWorkletParams[callbackId] = {
++ heap: HEAP8.buffer, // SharedArrayBuffer: the WASM linear memory, shared with the C++ thread
++ ringPtr: ringPtr,
++ ringSize: ringSize,
++ wposPtr: wposPtr,
++ volPtr: volPtr,
++ channels: channels,
++ fmt: fmt,
++ bps: bps
++ };
++ var code = [
++ 'class QtCapture extends AudioWorkletProcessor {',
++ ' constructor(opts) {',
++ ' super(opts);',
++ ' var options = opts.processorOptions;',
++ ' this._heap8 = new Int8Array(options.heap);', // typed views over the SharedArrayBuffer
++ ' this._heap16 = new Int16Array(options.heap);', // same buffer, different element width
++ ' this._heap32 = new Int32Array(options.heap);',
++ ' this._volumeConvInt = new Int32Array(1);',
++ ' this._volumeConvFloat = new Float32Array(this._volumeConvInt.buffer);',
++ ' this._sampleConvFloat = new Float32Array(1);',
++ ' this._sampleConvInt = new Int32Array(this._sampleConvFloat.buffer);',
++ ' this._ringBufferPtr = options.ringPtr | 0;',
++ ' this._ringBufferSize = options.ringSize | 0;',
++ ' this._writePositionIndex = (options.wposPtr >> 2) | 0;',
++ ' this._volumeIndex = (options.volPtr >> 2) | 0;',
++ ' this._numChannels = options.channels | 0;',
++ ' this._format = options.fmt | 0;',
++ ' this._bytesPerSample = options.bps | 0;',
++ ' }',
++ ' process(inputs) {',
++ ' var input = inputs[0];',
++ ' if (!input || !input.length || !input[0] || !input[0].length) return true;',
++ ' var numChannels = Math.min(input.length, this._numChannels);',
++ ' var samplesPerChannel = input[0].length;',
++ ' var bytesPerSample = this._bytesPerSample, ringSize = this._ringBufferSize, format = this._format;',
++ ' var ringPtr = this._ringBufferPtr;',
++ ' this._volumeConvInt[0] = Atomics.load(this._heap32, this._volumeIndex);',
++ ' var vol = this._volumeConvFloat[0];',
++ ' var writePos = Atomics.load(this._heap32, this._writePositionIndex);',
++ ' for (var i = 0; i < samplesPerChannel; i++) {',
++ ' for (var c = 0; c < numChannels; c++) {',
++ ' var sample = input[c][i] * vol;',
++ ' sample = sample < -1 ? -1 : sample > 1 ? 1 : sample;',
++ ' var offset = ringPtr + writePos;',
++ ' if (format === 1) { this._heap8 [offset] = ((sample + 1.0) * 127.5) | 0; }',
++ ' else if (format === 2) { this._heap16[offset>>1] = (sample * 32767) | 0; }',
++ ' else if (format === 3) { this._heap32[offset>>2] = (sample * 2147483647) | 0; }',
++ ' else { this._sampleConvFloat[0] = sample; this._heap32[offset>>2] = this._sampleConvInt[0]; }',
++ ' writePos = (writePos + bytesPerSample) % ringSize;',
++ ' }',
++ ' }',
++ ' Atomics.store(this._heap32, this._writePositionIndex, writePos);',
++ ' this.port.postMessage(null);',
++ ' return true;',
++ ' }',
++ '}',
++ 'registerProcessor("qt-audio-capture", QtCapture);'
++ ].join('\n');
++ var blob = new Blob([code], {type: 'application/javascript'});
++ var url = URL.createObjectURL(blob);
++ Emval.toValue(ctxHandle).audioWorklet.addModule(url).then(function() {
++ URL.revokeObjectURL(url);
++ Module._qt_onWorkletReady(callbackId);
++ });
++ });
++
++ EM_JS(void, qt_mt_setupWorkletPort, (EM_VAL nodeHandle, int callbackId), {
++ Emval.toValue(nodeHandle).port.onmessage = function() {
++ Module._qt_onAudioFrameReady(callbackId);
++ };
++ });
++
++
++ #else // QT_CONFIG(thread)
++
++ // Single-threaded: load a JS worklet that sends frames via MessagePort.
++
++ EM_JS(void, qt_st_loadWorkletModule, (EM_VAL ctxHandle, int instanceId), {
++ var ctx = Emval.toValue(ctxHandle);
++ if (!Module._qtAudioData) Module._qtAudioData = {};
++ Module._qtAudioData[instanceId] = [];
++ var code = [
++ 'class QtCapture extends AudioWorkletProcessor {',
++ ' process(inputs) {',
++ ' var input = inputs[0];',
++ ' if (input && input.length && input[0] && input[0].length) {',
++ ' var numChannels = input.length, samplesPerChannel = input[0].length;',
++ ' var buffer = new Float32Array(numChannels * samplesPerChannel);',
++ ' for (var c = 0; c < numChannels; c++) buffer.set(input[c], c * samplesPerChannel);',
++ ' this.port.postMessage({ch:numChannels,spch:samplesPerChannel,buf:buffer.buffer},[buffer.buffer]);',
++ ' }',
++ ' return true;',
++ ' }',
++ '}',
++ 'registerProcessor("qt-audio-capture", QtCapture);'
++ ].join('\n');
++ var blob = new Blob([code], {type: 'application/javascript'});
++ var url = URL.createObjectURL(blob);
++ ctx.audioWorklet.addModule(url).then(function() {
++ URL.revokeObjectURL(url);
++ Module._qt_onWorkletReady(instanceId);
++ });
++ });
++
++ EM_JS(EM_VAL, qt_st_createWorkletNode, (EM_VAL ctxHandle, int instanceId, int channelCount), {
++ var node = new AudioWorkletNode(Emval.toValue(ctxHandle), 'qt-audio-capture', {
++ numberOfInputs: 1,
++ numberOfOutputs: 0,
++ channelCount: channelCount,
++ channelCountMode: 'explicit'
++ });
++ node.port.onmessage = function(e) {
++ Module._qtAudioData[instanceId].push(e.data);
++ Module._qt_onAudioFrameReady(instanceId);
++ };
++ return Emval.toHandle(node);
++ });
++
++
++ // Dequeue the oldest frame into a C heap buffer (planar Float32, ch*spch floats).
++ EM_JS(int, qt_st_readFrame, (int instanceId, float *heapPtr, int *outCh, int *outSpch), {
++ var q = Module._qtAudioData && Module._qtAudioData[instanceId];
++ if (!q || !q.length) return 0;
++ var frame = q.shift();
++ var data = new Float32Array(frame.buf);
++ HEAPF32.set(data, heapPtr >> 2);
++ HEAP32[outCh >> 2] = frame.ch;
++ HEAP32[outSpch >> 2] = frame.spch;
++ return data.length;
++ });
++
++
++ // Interleave planar float and convert to PCM — used by the single-threaded path.
++ // multithread does this in worklet processor, so no need to share this.
++ static void convertFloatToPcm(const float *planarData, int numChannels, int samplesPerChannel,
++ float volume, QAudioFormat::SampleFormat fmt, int bytesPerSample,
++ char *out)
++ {
++ switch (fmt) {
++ case QAudioFormat::UInt8:
++ for (int i = 0; i < samplesPerChannel; ++i)
++ for (int ch = 0; ch < numChannels; ++ch, out += bytesPerSample) {
++ const float s = qBound(-1.0f, planarData[ch * samplesPerChannel + i] * volume, 1.0f);
++ *reinterpret_cast<quint8 *>(out) = static_cast<quint8>((s + 1.0f) * 127.5f);
++ }
++ break;
++ case QAudioFormat::Int16:
++ for (int i = 0; i < samplesPerChannel; ++i)
++ for (int ch = 0; ch < numChannels; ++ch, out += bytesPerSample) {
++ const float s = qBound(-1.0f, planarData[ch * samplesPerChannel + i] * volume, 1.0f);
++ *reinterpret_cast<qint16 *>(out) = static_cast<qint16>(s * 32767.0f);
++ }
++ break;
++ case QAudioFormat::Int32:
++ for (int i = 0; i < samplesPerChannel; ++i)
++ for (int ch = 0; ch < numChannels; ++ch, out += bytesPerSample) {
++ const float s = qBound(-1.0f, planarData[ch * samplesPerChannel + i] * volume, 1.0f);
++ *reinterpret_cast<qint32 *>(out) = static_cast<qint32>(s * 2147483647.0f);
++ }
++ break;
++ case QAudioFormat::Float:
++ for (int i = 0; i < samplesPerChannel; ++i)
++ for (int ch = 0; ch < numChannels; ++ch, out += bytesPerSample) {
++ const float s = qBound(-1.0f, planarData[ch * samplesPerChannel + i] * volume, 1.0f);
++ *reinterpret_cast<float *>(out) = s;
++ }
++ break;
++ default:
++ break;
++ }
++ }
++
++ #endif // QT_CONFIG(thread)
++
++ class QWasmAudioSourceDevice : public QIODevice
++ {
++ QWasmAudioSource *m_source;
++ public:
++ explicit QWasmAudioSourceDevice(QWasmAudioSource *src) : QIODevice(src), m_source(src) {}
++ bool isSequential() const override { return true; }
++ protected:
++ qint64 readData(char *data, qint64 maxlen) override { return m_source->readFromBuffer(data, maxlen); }
++ qint64 writeData(const char *, qint64) override { Q_UNREACHABLE(); return 0; }
++ };
++
++ // ===========================================================================
++ // QWasmAudioSource implementation
++ // ===========================================================================
++
++ QWasmAudioSource::QWasmAudioSource(QAudioDevice device,
++ const QAudioFormat &fmt,
++ QObject *parent)
++ : QPlatformAudioSource(std::move(device), fmt, parent)
++ {
++ m_bufferSize = m_format.bytesForDuration(DEFAULT_BUFFER_DURATION);
++ }
++
++ QWasmAudioSource::~QWasmAudioSource()
++ {
++ teardownPipeline();
++ }
++
++ void QWasmAudioSource::start(QIODevice *device)
++ {
++ m_device = device;
++ startPipeline(true);
++ }
++
++ QIODevice *QWasmAudioSource::start()
++ {
++ auto *dev = new QWasmAudioSourceDevice(this);
++ dev->open(QIODevice::ReadOnly);
++ m_device = dev;
++ startPipeline(false);
++ return dev;
++ }
++
++ void QWasmAudioSource::startPipeline(bool pullMode)
++ {
++ if (m_running || m_inputStream)
++ return;
++
++ if (m_format.sampleFormat() == QAudioFormat::Unknown
++ || m_format.channelCount() < 1
++ || m_format.channelCount() > 8) {
++ qWarning() << "QWasmAudioSource: unsupported format" << m_format;
++ setError(QAudio::OpenError);
++ return;
++ }
++
++ m_pullMode = pullMode;
++ m_processed = 0;
++ m_streamReady = false;
++ m_workletReady = false;
++ m_callbackId = ++s_nextId;
++ s_registry.insert(m_callbackId, this);
++
++ #if QT_CONFIG(thread)
++ m_ringBuffer.resize(m_format.bytesForDuration(RING_BUFFER_DURATION));
++ m_writePos.store(0, std::memory_order_relaxed);
++ m_readPos.store(0, std::memory_order_relaxed);
++ #endif
++
++ m_inputStream = new JsMediaInputStream(this);
++ m_inputStream->setUseAudio(true);
++ m_inputStream->setUseVideo(false);
++ connect(m_inputStream, &JsMediaInputStream::mediaAudioStreamReady, this, [this]() {
++ m_mediaStream = m_inputStream->getMediaStream();
++ m_streamReady = true;
++ connectMediaStreamIfReady();
++ });
++ m_inputStream->setStreamDevice(m_audioDevice.id().toStdString());
++
++ #if QT_CONFIG(thread)
++ {
++ auto attrs = emscripten::val::object();
++ attrs.set("latencyHint", emscripten::val("interactive"));
++ attrs.set("sampleRate", m_format.sampleRate());
++ auto sinkId = emscripten::val::object();
++ sinkId.set("type", emscripten::val("none")); // do not send to output device
++ attrs.set("sinkId", sinkId);
++ m_audioContext = emscripten::val::global("AudioContext").new_(attrs);
++ }
++ // m_ringBuffer lives in the WASM heap (a SharedArrayBuffer in threaded builds);
++ // the worklet accesses it directly via HEAP8.buffer without any copy.
++ qt_loadWorkletModule(m_audioContext.as_handle(),
++ static_cast<int>(reinterpret_cast<intptr_t>(m_ringBuffer.data())),
++ static_cast<int>(m_ringBuffer.size()),
++ static_cast<int>(reinterpret_cast<intptr_t>(&m_writePos)),
++ static_cast<int>(reinterpret_cast<intptr_t>(&m_volumeAtomic)),
++ m_format.channelCount(),
++ static_cast<int>(m_format.sampleFormat()),
++ m_format.bytesPerSample(),
++ m_callbackId);
++ #else
++ auto attrs = emscripten::val::object();
++ attrs.set("latencyHint", emscripten::val("interactive"));
++ attrs.set("sampleRate", m_format.sampleRate());
++ auto sinkId = emscripten::val::object();
++ sinkId.set("type", emscripten::val("none")); // do not send to output device
++ attrs.set("sinkId", sinkId);
++ m_audioContext = emscripten::val::global("AudioContext").new_(attrs);
++ qt_st_loadWorkletModule(m_audioContext.as_handle(), m_callbackId);
++ #endif
++
++ m_elapsedTimer.start();
++ }
++
++ void QWasmAudioSource::stop()
++ {
++ if (!m_running)
++ return;
++ if (m_pullMode)
++ deliverBufferedData();
++ teardownPipeline();
++ if (!m_pullMode)
++ m_device->deleteLater();
++ m_device = nullptr;
++ }
++
++ void QWasmAudioSource::reset()
++ {
++ teardownPipeline();
++ m_processed = 0;
++ setError(QAudio::NoError);
++ m_device = nullptr;
++ }
++
++ void QWasmAudioSource::setBufferSize(qsizetype value)
++ {
++ m_bufferSize = value;
++ }
++
++ qsizetype QWasmAudioSource::bufferSize() const
++ {
++ return m_bufferSize;
++ }
++
++ qint64 QWasmAudioSource::processedUSecs() const
++ {
++ return m_format.durationForBytes(m_processed);
++ }
++
++ QAudio::State QWasmAudioSource::state() const
++ {
++ if (!m_running) return QAudio::StoppedState;
++ if (m_suspended) return QAudio::SuspendedState;
++ return QAudio::ActiveState;
++ }
++
++ void QWasmAudioSource::setVolume(float vol)
++ {
++ QPlatformAudioSource::setVolume(vol);
++ #if QT_CONFIG(thread)
++ m_volumeAtomic.store(vol, std::memory_order_relaxed);
++ #endif
++ }
++
++ void QWasmAudioSource::suspend()
++ {
++ if (!m_running || m_suspended)
++ return;
++ m_suspended = true;
++ m_audioContext.call<void>("suspend");
++ }
++
++ void QWasmAudioSource::resume()
++ {
++ if (!m_running || !m_suspended)
++ return;
++ m_suspended = false;
++ m_audioContext.call<void>("resume");
++ }
++
++ void QWasmAudioSource::connectMediaStreamIfReady()
++ {
++ if (!m_streamReady || !m_workletReady)
++ return;
++ #if QT_CONFIG(thread)
++ auto nodeOpts = emscripten::val::object();
++ nodeOpts.set("numberOfInputs", 1);
++ nodeOpts.set("numberOfOutputs", 0);
++ nodeOpts.set("channelCount", m_format.channelCount());
++ nodeOpts.set("channelCountMode", emscripten::val("explicit"));
++ nodeOpts.set("processorOptions",
++ emscripten::val::module_property("_qtWorkletParams")[m_callbackId]);
++ m_workletNode = emscripten::val::global("AudioWorkletNode")
++ .new_(m_audioContext, std::string("qt-audio-capture"), nodeOpts);
++ qt_mt_setupWorkletPort(m_workletNode.as_handle(), m_callbackId);
++ m_audioContext.call<emscripten::val>("createMediaStreamSource", m_mediaStream)
++ .call<void>("connect", m_workletNode, 0, 0);
++ m_audioContext.call<void>("resume");
++ m_running.store(true, std::memory_order_release);
++ #else
++ m_workletNode = emscripten::val::take_ownership(
++ qt_st_createWorkletNode(m_audioContext.as_handle(), m_callbackId, m_format.channelCount()));
++ m_audioContext.call<emscripten::val>("createMediaStreamSource", m_mediaStream)
++ .call<void>("connect", m_workletNode);
++ m_audioContext.call<void>("resume");
++ m_running = true;
++ #endif
++ }
++
++ void QWasmAudioSource::deliverBufferedData()
++ {
++ if (!m_running || !m_device || m_suspended)
++ return;
++
++ #if QT_CONFIG(thread)
++ const int avail = static_cast<int>(bytesReady());
++ if (avail == 0)
++ return;
++ if (m_pullMode) {
++ const int ringSize = m_ringBuffer.size();
++ int rpos = m_readPos.load(std::memory_order_relaxed);
++ const int tail = ringSize - rpos;
++ if (avail <= tail) {
++ m_device->write(m_ringBuffer.constData() + rpos, avail);
++ } else {
++ m_device->write(m_ringBuffer.constData() + rpos, tail);
++ m_device->write(m_ringBuffer.constData(), avail - tail);
++ }
++ m_processed += avail;
++ m_readPos.store((rpos + avail) % ringSize, std::memory_order_release);
++ } else {
++ Q_EMIT m_device->readyRead();
++ }
++ #else
++ float frameBuf[128 * 8]; // one Web Audio quantum: 128 frames × 8 ch max
++ int numCh = 0, spch = 0;
++ const int bytesPerSample = m_format.bytesPerSample();
++ const float vol = volume();
++ m_pendingData.reserve(m_pendingData.size() + m_bufferSize);
++ while (qt_st_readFrame(m_callbackId, frameBuf, &numCh, &spch) > 0) {
++ const int prevSize = m_pendingData.size();
++ m_pendingData.resize(prevSize + spch * numCh * bytesPerSample);
++ convertFloatToPcm(frameBuf, numCh, spch, vol,
++ m_format.sampleFormat(), bytesPerSample,
++ m_pendingData.data() + prevSize);
++ }
++ if (m_pendingData.isEmpty())
++ return;
++ if (m_pullMode) {
++ m_processed += m_pendingData.size();
++ m_device->write(m_pendingData);
++ m_pendingData.clear();
++ } else {
++ Q_EMIT m_device->readyRead();
++ }
++ #endif
++ }
++
++ qint64 QWasmAudioSource::readFromBuffer(char *data, qint64 maxlen)
++ {
++ #if QT_CONFIG(thread)
++ const int avail = static_cast<int>(bytesReady());
++ const int chunk = static_cast<int>(qMin(maxlen, static_cast<qint64>(avail)));
++ if (chunk == 0)
++ return 0;
++ const int ringSize = m_ringBuffer.size();
++ int rpos = m_readPos.load(std::memory_order_relaxed);
++ const int tail = ringSize - rpos;
++ if (chunk <= tail) {
++ memcpy(data, m_ringBuffer.constData() + rpos, chunk);
++ } else {
++ memcpy(data, m_ringBuffer.constData() + rpos, tail);
++ memcpy(data + tail, m_ringBuffer.constData(), chunk - tail);
++ }
++ m_processed += chunk;
++ m_readPos.store((rpos + chunk) % ringSize, std::memory_order_release);
++ return chunk;
++ #else
++ const qint64 chunk = qMin(maxlen, static_cast<qint64>(m_pendingData.size()));
++ if (chunk == 0)
++ return 0;
++ memcpy(data, m_pendingData.constData(), chunk);
++ m_pendingData.remove(0, chunk);
++ m_processed += chunk;
++ return chunk;
++ #endif
++ }
++
++ qsizetype QWasmAudioSource::bytesReady() const
++ {
++ if (!m_running)
++ return 0;
++ #if QT_CONFIG(thread)
++ const int w = m_writePos.load(std::memory_order_acquire);
++ const int r = m_readPos.load(std::memory_order_relaxed);
++ return static_cast<qsizetype>((w - r + m_ringBuffer.size()) % m_ringBuffer.size());
++ #else
++ return static_cast<qsizetype>(m_pendingData.size());
++ #endif
++ }
++
++ void QWasmAudioSource::teardownPipeline()
++ {
++ if (m_callbackId) {
++ s_registry.remove(m_callbackId);
++ auto paramsMap = emscripten::val::module_property("_qtWorkletParams");
++ if (!paramsMap.isUndefined()) paramsMap.set(m_callbackId, emscripten::val::undefined());
++ auto dataMap = emscripten::val::module_property("_qtAudioData");
++ if (!dataMap.isUndefined()) dataMap.set(m_callbackId, emscripten::val::array());
++ m_callbackId = 0;
++ }
++ m_workletNode = emscripten::val::undefined();
++ if (!m_audioContext.isUndefined()) {
++ m_audioContext.call<void>("close");
++ m_audioContext = emscripten::val::undefined();
++ }
++ #if QT_CONFIG(thread)
++ m_running.store(false, std::memory_order_release);
++ #else
++ m_running = false;
++ m_pendingData.clear();
++ #endif
++ m_suspended = m_workletReady = m_streamReady = false;
++ delete m_inputStream;
++ m_inputStream = nullptr;
++ m_mediaStream = emscripten::val::undefined();
++ m_elapsedTimer.invalidate();
++ }
++
++ QT_END_NAMESPACE