diff --git a/.gitignore b/.gitignore index 935d6c2..69583df 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ !docs !docs/* +!native +!native/* + !.gitignore !.npmignore !index.js diff --git a/index.js b/index.js index 7d1b527..d306956 100644 --- a/index.js +++ b/index.js @@ -3,31 +3,11 @@ import dgram from 'node:dgram' import EventEmitter from 'node:events' import { performance } from 'node:perf_hooks' import { PassThrough } from 'node:stream' -import WebSocket from '@performanc/pwsl' -import Sodium from './sodium.js' - -let _NativeQueueManager = null -let sharedNativeQueue = null - -try { - const mod = await import('@toddynnn/udpqueue') - _NativeQueueManager = - mod?.UdpQueueManager ?? mod?.default?.UdpQueueManager ?? null - - if (!_NativeQueueManager) { - throw new Error('UdpQueueManager export not found') - } - sharedNativeQueue = new _NativeQueueManager(400) -} catch (err) { - _NativeQueueManager = null - sharedNativeQueue = null +import WebSocket from '@performanc/pwsl' - console.log( - '[VoiceUDP] @toddynnn/udpqueue not available — falling back to JS setTimeout pacing' - ) - console.log(`[VoiceUDP] Reason: ${err?.message ?? err}`) -} +import Sodium from './sodium.js' +import { NativeAudioSender } from './native/index.js' let MLS = null try { @@ -54,7 +34,6 @@ const OPUS_SILENCE_FRAME = Buffer.from([0xf8, 0xff, 0xfe]) const OPUS_SILENCE_FRAME_LENGTH = 3 const TIMESTAMP_INCREMENT = (OPUS_SAMPLE_RATE / 1000) * OPUS_FRAME_DURATION -const OPUS_FRAME_SIZE = (OPUS_SAMPLE_RATE * OPUS_FRAME_DURATION) / 1000 const _MAX_TIMESTAMP = 2 ** 32 const _MAX_SEQUENCE = 2 ** 16 @@ -201,7 +180,7 @@ class VoiceMLS extends EventEmitter { this._pendingKeyPackage = keyPackage if (emitKeyPackage) this.emit('keyPackage', keyPackage) - return + return; } if (this.session) { @@ -286,7 +265,7 @@ class VoiceMLS extends EventEmitter { } recoverFromInvalidTransition(transitionId) { - if (this.reinitializing) return + if (this.reinitializing) return; this.reinitializing = true this.consecutiveFailures = 0 @@ -465,19 +444,18 @@ class Connection extends EventEmitter { this.player = { sequence: crypto.randomInt(_MAX_SEQUENCE), timestamp: crypto.randomInt(_MAX_TIMESTAMP) >>> 0, - nextPacket: 0, lastPacketTime: null } + this._nativeSender = null + this._reconnectSuccessCount = 0 this._lastStableTime = 0 this.nonce = 0 - this.nonceBuffer = Connection._createNonceBuffer(this.encryption) this._recvNonce24 = Buffer.alloc(24) this._recvNonce12 = this._recvNonce24.subarray(0, 12) - this._sendBuffer = Buffer.allocUnsafe(1232) this._onUdpSend = (error) => { if (error) this.statistics.packetsLost++ @@ -485,8 +463,6 @@ class Connection extends EventEmitter { this.statistics.packetsExpected++ } - this.playTimeout = null - this.challengeTimeout = null this.connectTimeout = null this.audioStream = null @@ -502,10 +478,6 @@ class Connection extends EventEmitter { this._silenceKeepaliveTimer = null this._silenceFrameTimeout = null - this._nativeQueueManager = obj.nativeQueueManager ?? sharedNativeQueue - this._nativeQueueKey = null - this._drainInterval = null - this.ssrcs = new Map() this._userIdToSSRCs = new Map() @@ -514,17 +486,6 @@ class Connection extends EventEmitter { this._reconnectCircuitBreakerThreshold = 5 this._reconnectCircuitBreakerWindowMs = 60000 this._loggedMissingSecretKey = false - - this.stuckTimeout = 2000 - - this._boundMarkAsStoppable = () => this._markAsStoppable() - this._boundPacketInterval = this._packetInterval.bind(this) - } - - static _createNonceBuffer(encryption) { - return encryption === 'aead_aes256_gcm_rtpsize' - ? Buffer.alloc(12) - : Buffer.alloc(24) } _registerSSRC(ssrc) { @@ -554,13 +515,13 @@ class Connection extends EventEmitter { } _wsSendJSON(op, d) { - if (!this.ws) return + if (!this.ws) return; this.ws.send(JSON.stringify({ op, d })) } _wsSendBinary(opcode, payload) { const ws = this.ws - if (!ws) return + if (!ws) return; const p = payload ? Buffer.isBuffer(payload) @@ -585,9 +546,9 @@ class Connection extends EventEmitter { } _initMLSSessionIfNeeded(protocolVersionHint) { - if (!MLS) return - if (!this.channelId) return - if (this.mlsSession) return + if (!MLS) return; + if (!this.channelId) return; + if (this.mlsSession) return; const initialVersion = typeof protocolVersionHint === 'number' @@ -639,14 +600,14 @@ class Connection extends EventEmitter { } _ensureKeyPackageSent() { - if (!MLS) return - if (this._keyPackageSent) return + if (!MLS) return; + if (this._keyPackageSent) return; if (!this.mlsSession) { this._initMLSSessionIfNeeded(this.mlsProtocolVersion) } - if (!this.mlsSession) return + if (!this.mlsSession) return; try { const kp = @@ -666,9 +627,9 @@ class Connection extends EventEmitter { } _drainBufferedProposals() { - if (!this.mlsSession) return - if (!this.mlsSession.externalSenderSet) return - if (this.pendingProposals.length === 0) return + if (!this.mlsSession) return; + if (!this.mlsSession.externalSenderSet) return; + if (this.pendingProposals.length === 0) return; const connected = Array.from(this.connectedUserIds) const proposals = this.pendingProposals @@ -704,7 +665,7 @@ class Connection extends EventEmitter { } udpSend(data, cb) { - if (!this.udp || !this.udpInfo) return + if (!this.udp || !this.udpInfo) return; if (!cb) { cb = (error) => { @@ -716,7 +677,7 @@ class Connection extends EventEmitter { this.udp.send(data, this.udpInfo.port, this.udpInfo.ip, cb) } catch (err) { if (err.code === 'ERR_SOCKET_DGRAM_NOT_RUNNING') { - return + return; } this.emit('error', err) } @@ -735,7 +696,7 @@ class Connection extends EventEmitter { } _setSpeaking(value) { - if (!this.ws || !this.udpInfo) return + if (!this.ws || !this.udpInfo) return; this._wsSendJSON(5, { speaking: value, delay: 0, ssrc: this.udpInfo.ssrc }) } @@ -746,7 +707,7 @@ class Connection extends EventEmitter { if (!udp || !udpInfo) { reject(new Error('UDP socket not ready for IP discovery')) - return + return; } let settled = false @@ -763,21 +724,21 @@ class Connection extends EventEmitter { } const finish = (fn, value) => { - if (settled) return + if (settled) return; settled = true cleanup() fn(value) } const onMessage = (message) => { - if (!message || message.length < 10) return + if (!message || message.length < 10) return; const type = message.readUInt16BE(0) - if (type !== 2) return + if (type !== 2) return; const zeroIndex = message.indexOf(0, 8) - if (zeroIndex === -1) return - if (message.length < 2) return + if (zeroIndex === -1) return; + if (message.length < 2) return; finish(resolve, { ip: message.subarray(8, zeroIndex).toString('utf8'), @@ -808,122 +769,14 @@ class Connection extends EventEmitter { }) } - _setupNativeQueue() { - if (!this._nativeQueueManager) return - if (typeof this._nativeQueueManager.createQueue !== 'function') return - - if (this._nativeQueueKey !== null) { - this._destroyNativeQueue() - } - - try { - this._nativeQueueKey = this._nativeQueueManager.createQueue(400) - this._startDrainLoop() - } catch { - this._nativeQueueKey = null - } - } - - _sendPacketViaQueue(packet) { - if (this._nativeQueueKey === null || !this._nativeQueueManager) { - return false - } - - const queued = this._nativeQueueManager.pushPacket( - this._nativeQueueKey, - packet - ) - - this.player.lastPacketTime = performance.now() - if (!queued) { - this.statistics.packetsLost++ - this.statistics.packetsExpected++ - } - - return queued - } - - _clearNativeQueue() { - if (this._nativeQueueKey === null || !this._nativeQueueManager) return - if (typeof this._nativeQueueManager.clearQueue !== 'function') return - - try { - this._nativeQueueManager.clearQueue(this._nativeQueueKey) - } catch {} - } - - _destroyNativeQueue() { - this._stopDrainLoop() - - if (this._nativeQueueKey === null || !this._nativeQueueManager) return - if (typeof this._nativeQueueManager.deleteQueue !== 'function') { - this._nativeQueueKey = null - return - } - - try { - this._nativeQueueManager.deleteQueue(this._nativeQueueKey) - } catch {} - - this._nativeQueueKey = null - } - - _startDrainLoop() { - if (this._drainInterval) return - this._drainInterval = setInterval(() => this._drainNativeQueue(), 2) - } - - _stopDrainLoop() { - if (!this._drainInterval) return - clearInterval(this._drainInterval) - this._drainInterval = null - } - - _drainNativeQueue() { - if (this._nativeQueueKey === null || !this._nativeQueueManager) return - if (!this.udp || !this.udpInfo) return - - const hasDrainQueue = - typeof this._nativeQueueManager.drainQueue === 'function' - - if (hasDrainQueue) { - const nowNs = Number(process.hrtime.bigint()) - const packet = this._nativeQueueManager.drainQueue( - this._nativeQueueKey, - nowNs - ) - if (packet) { - this.udp.send(packet, this.udpInfo.port, this.udpInfo.ip, (err) => { - if (err) this.statistics.packetsLost++ - else this.statistics.packetsSent++ - this.statistics.packetsExpected++ - }) - } - return - } - - if (typeof this._nativeQueueManager.drainAll === 'function') { - const nowNs = Number(process.hrtime.bigint()) - const items = this._nativeQueueManager.drainAll(nowNs) - for (const item of items) { - if (item.queue_key !== this._nativeQueueKey) continue - this.udp.send(item.data, this.udpInfo.port, this.udpInfo.ip, (err) => { - if (err) this.statistics.packetsLost++ - else this.statistics.packetsSent++ - this.statistics.packetsExpected++ - }) - } - } - } - _startSilenceKeepalive() { - if (this._silenceKeepaliveTimer) return + if (this._silenceKeepaliveTimer) return; // https://github.com/Snazzah/davey/blob/master/docs/USAGE.md#handling-voice-packets // Silence frames are already handled by the MLS session if (this.mlsSession && this.mlsSession.protocolVersion > 0) { this._setSpeaking(1 << 0) - return + return; } this._setSpeaking(1 << 0) @@ -931,14 +784,14 @@ class Connection extends EventEmitter { this._silenceKeepaliveTimer = setInterval(() => { if (!this.udpInfo?.secretKey || this.connectedUserIds.size > 0) { this._stopSilenceKeepalive() - return + return; } this.sendAudioChunk(OPUS_SILENCE_FRAME) }, 5000) } _stopSilenceKeepalive() { - if (!this._silenceKeepaliveTimer) return + if (!this._silenceKeepaliveTimer) return; clearInterval(this._silenceKeepaliveTimer) this._silenceKeepaliveTimer = null this._setSpeaking(0) @@ -983,7 +836,7 @@ class Connection extends EventEmitter { }, false ) - return + return; } } @@ -1037,11 +890,11 @@ class Connection extends EventEmitter { this.ws.on('message', (data) => { if (typeof data === 'string') { - let payload + let payload = null try { payload = JSON.parse(data) } catch { - return + return; } if (typeof payload.seq === 'number') this.lastSequence = payload.seq @@ -1051,7 +904,7 @@ class Connection extends EventEmitter { } const buf = toNodeBuffer(data) - if (!buf) return + if (!buf) return; const maybeJSON = tryParseJSONFromBuffer(buf) if (maybeJSON) { @@ -1062,7 +915,7 @@ class Connection extends EventEmitter { } const parsed = this._parseServerBinaryMessage(buf) - if (!parsed) return + if (!parsed) return; const { opcode, payload } = parsed @@ -1164,7 +1017,7 @@ class Connection extends EventEmitter { }) this.ws.on('close', (code, reason) => { - if (!this.ws) return + if (!this.ws) return; const closeCode = DISCORD_CLOSE_CODES[code] @@ -1189,19 +1042,16 @@ class Connection extends EventEmitter { const savedUdp = this.udp const savedUdpInfo = this.udpInfo - const savedNativeQueueKey = this._nativeQueueKey const savedSequence = this.player.sequence const savedTimestamp = this.player.timestamp this.udp = null this.udpInfo = null - this._nativeQueueKey = null this._destroyConnection(code, reason) this.udp = savedUdp this.udpInfo = savedUdpInfo - this._nativeQueueKey = savedNativeQueueKey this.player.sequence = savedSequence this.player.timestamp = savedTimestamp @@ -1235,7 +1085,7 @@ class Connection extends EventEmitter { _cleanupSSRCsForUserId(userId) { const uid = String(userId) const ssrcSet = this._userIdToSSRCs.get(uid) - if (!ssrcSet) return + if (!ssrcSet) return; for (const ssrc of ssrcSet) { const entry = this.ssrcs.get(ssrc) @@ -1276,17 +1126,17 @@ class Connection extends EventEmitter { this.udp = dgram.createSocket('udp4') this.udp.on('message', (data) => { - if (data.length <= 12) return + if (data.length <= 12) return; const rtpVersion = data[0] >> 6 - if (rtpVersion !== 2) return + if (rtpVersion !== 2) return; const payloadType = data[1] & 0x7f - if (payloadType !== 0x78) return + if (payloadType !== 0x78) return; const ssrc = data.readUInt32BE(8) const userData = this.ssrcs.get(ssrc) - if (!userData || !this.udpInfo?.secretKey) return + if (!userData || !this.udpInfo?.secretKey) return; const hasPadding = !!(data[0] & 0b100000) const hasExtension = !!(data[0] & 0b10000) @@ -1300,23 +1150,23 @@ class Connection extends EventEmitter { nonce.fill(0) data.copy(nonce, 0, data.length - 4, data.length) - let headerSize = 12 + cc * 4 + const headerSize = 12 + cc * 4 let extensionLengthInWords = 0 - if (data.length < headerSize) return + if (data.length < headerSize) return; if (hasExtension) { - if (data.length < headerSize + 4) return + if (data.length < headerSize + 4) return; extensionLengthInWords = data.readUInt16BE(headerSize + 2) headerSize += 4 } const header = data.subarray(0, headerSize) - let decryptedPacket + let decryptedPacket = null if (this.encryption === 'aead_aes256_gcm_rtpsize') { const trailerLength = 16 + 4 - if (data.length < headerSize + trailerLength) return + if (data.length < headerSize + trailerLength) return; const encrypted = data.subarray( headerSize, @@ -1345,10 +1195,10 @@ class Connection extends EventEmitter { 'error', new Error(`Failed to decrypt AES-256-GCM packet: ${e.message}`) ) - return + return; } } else if (this.encryption === 'aead_xchacha20_poly1305_rtpsize') { - if (data.length < headerSize + 4) return + if (data.length < headerSize + 4) return; const encrypted = data.subarray(headerSize, data.length - 4) try { @@ -1366,20 +1216,20 @@ class Connection extends EventEmitter { `Failed to decrypt XChaCha20-Poly1305 packet: ${e.message}` ) ) - return + return; } } else { - return + return; } - if (!decryptedPacket || decryptedPacket.length === 0) return + if (!decryptedPacket || decryptedPacket.length === 0) return; if (hasPadding) { const paddingAmount = decryptedPacket.readUInt8( decryptedPacket.length - 1 ) if (paddingAmount > 0) { - if (paddingAmount >= decryptedPacket.length) return + if (paddingAmount >= decryptedPacket.length) return; decryptedPacket = decryptedPacket.subarray( 0, decryptedPacket.length - paddingAmount @@ -1389,7 +1239,7 @@ class Connection extends EventEmitter { const extensionDataLength = extensionLengthInWords * 4 if (hasExtension) { - if (extensionDataLength > decryptedPacket.length) return + if (extensionDataLength > decryptedPacket.length) return; decryptedPacket = decryptedPacket.subarray(extensionDataLength) } @@ -1400,12 +1250,12 @@ class Connection extends EventEmitter { !this.mlsSession.session?.ready && this.mlsSession.protocolVersion !== 0 ) { - return + return; } const decrypted = this.mlsSession.decrypt(packet, userData.userId) if (decrypted !== null) packet = decrypted - else return + else return; } if ( @@ -1415,7 +1265,7 @@ class Connection extends EventEmitter { packet[2] === 0xfe ) { if (userData.stream.destroyed || userData.stream.writableEnded) - return + return; this.emit('speakEnd', userData.userId, ssrc) userData.stream.end() } else { @@ -1440,7 +1290,7 @@ class Connection extends EventEmitter { this.udp.on('error', (error) => this.emit('error', error)) this.udp.on('close', () => { - if (!this.ws) return + if (!this.ws) return; this.emit( 'error', new Error( @@ -1450,7 +1300,7 @@ class Connection extends EventEmitter { this._destroy({ status: 'disconnected', reason: 'udp_closed' }) }) - let serverInfo + let serverInfo = null try { serverInfo = await this._ipDiscovery() } catch (error) { @@ -1459,7 +1309,7 @@ class Connection extends EventEmitter { { status: 'disconnected', reason: 'ip_discovery_failed' }, false ) - return + return; } if ( @@ -1479,11 +1329,9 @@ class Connection extends EventEmitter { { status: 'disconnected', reason: 'ip_discovery_invalid' }, false ) - return + return; } - this._setupNativeQueue() - if (this.udpKeepAliveInterval) clearInterval(this.udpKeepAliveInterval) this.udpKeepAliveInterval = setInterval(() => { this._sendUdpKeepAlive() @@ -1509,8 +1357,7 @@ class Connection extends EventEmitter { if (payload.d.mode && payload.d.mode !== this.encryption) { this.encryption = payload.d.mode - this.nonceBuffer = Connection._createNonceBuffer(this.encryption) - } + } this.udpInfo.secretKey = Buffer.from(payload.d.secret_key) @@ -1518,6 +1365,14 @@ class Connection extends EventEmitter { // so reset it to 0 to start a new fresh sequence. this.nonce = 0 + // Native sender must be recreated with the new key + if (this._nativeSender) { + try { + this._nativeSender.destroy() + } catch { /* ignore */ } + this._nativeSender = null + } + if (!this.udpInfo.secretKey || this.udpInfo.secretKey.length === 0) { this.emit( 'error', @@ -1579,7 +1434,7 @@ class Connection extends EventEmitter { this.hbInterval = null if (this.ws) this.ws.close(4015, 'Heartbeat timeout') - return + return; } this.hbIntervalMissed++ @@ -1698,7 +1553,6 @@ class Connection extends EventEmitter { } _sendEncryptedPacket(packet) { - if (this._sendPacketViaQueue(packet)) return this.player.lastPacketTime = performance.now() this.udpSend(packet, this._onUdpSend) } @@ -1715,127 +1569,84 @@ class Connection extends EventEmitter { ) ) } - return + return; } this._loggedMissingSecretKey = false if (this.mlsSession) { chunk = this.mlsSession.encrypt(chunk) - if (!chunk) return - } - - this._sendBuffer[0] = 0x80 - this._sendBuffer[1] = 0x78 - this._sendBuffer.writeUInt16BE(this.player.sequence, 2) - this._sendBuffer.writeUInt32BE(this.player.timestamp, 4) - this._sendBuffer.writeUInt32BE(udpInfo.ssrc, 8) - - this.nonceBuffer.writeUInt32BE(this.nonce, 0) - - const header = this._sendBuffer.subarray(0, 12) - - switch (this.encryption) { - case 'aead_aes256_gcm_rtpsize': { - const cipher = crypto.createCipheriv( - 'aes-256-gcm', - udpInfo.secretKey, - this.nonceBuffer - ) - cipher.setAAD(header) - - const ciphertext = cipher.update(chunk) - ciphertext.copy(this._sendBuffer, 12) - let off = 12 + ciphertext.length - - const final = cipher.final() - if (final.length) { - final.copy(this._sendBuffer, off) - off += final.length - } - - const authTag = cipher.getAuthTag() - authTag.copy(this._sendBuffer, off) - off += authTag.length - - const packetSize = off + 4 - const packet = this._sendBuffer.subarray(0, packetSize) - - packet.writeUInt32BE(this.nonce, off) - - this._sendEncryptedPacket(packet) - - break + if (!chunk) { + return; } + } - case 'aead_xchacha20_poly1305_rtpsize': { - const abytes = Sodium.ABYTES ?? 16 - const cipherLen = chunk.length + abytes - const packetSize = 12 + cipherLen + 4 - const packet = this._sendBuffer.subarray(0, packetSize) - - const out = packet.subarray(12, 12 + cipherLen) - - if (Sodium.crypto_aead_xchacha20poly1305_ietf_encrypt_into) { - Sodium.crypto_aead_xchacha20poly1305_ietf_encrypt_into( - out, - chunk, - header, - this.nonceBuffer, - udpInfo.secretKey - ) - } else { - const encrypted = Sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( - chunk, - header, - this.nonceBuffer, - udpInfo.secretKey - ) - encrypted.copy(out) - } - - packet.writeUInt32BE(this.nonce, 12 + cipherLen) + /* INFO: Lazy-initialise the native C sender */ + if (!this._nativeSender) { + this._nativeSender = new NativeAudioSender( + this.encryption === 'aead_aes256_gcm_rtpsize' + ? NativeAudioSender.AES256_GCM + : NativeAudioSender.XCHACHA20, + udpInfo.secretKey, + udpInfo.ssrc, + this.player.sequence, + this.player.timestamp + ) + } + /* INFO: Native encrypt */ + { + const packet = this._nativeSender.encrypt(chunk) + if (packet) { this._sendEncryptedPacket(packet) - - break + this.player.sequence = this._nativeSender.sequence + this.player.timestamp = this._nativeSender.timestamp + this.nonce = this._nativeSender.nonce } - - default: - return } - // note: increment timestamp/nonce/sequence after sending, not before - // in op 4, the first packet should be nonce=0 - // if sent before, nonce=0 is skipped and the first packet uses 1. - this.player.timestamp = (this.player.timestamp + TIMESTAMP_INCREMENT) >>> 0 - this.player.sequence = (this.player.sequence + 1) & 0xffff - this.nonce = (this.nonce + 1) >>> 0 } play(audioStream) { if (!this.udpInfo) { this.emit('error', new Error('Cannot play audio without UDP info.')) - return + return; } const oldAudioStream = this.audioStream + const rawStream = audioStream.stream ?? audioStream - audioStream.once('readable', () => { - if (oldAudioStream) { - oldAudioStream.removeListener( - 'finishBuffering', - this._boundMarkAsStoppable - ) - } - - if (oldAudioStream && this.playTimeout) { - clearTimeout(this.playTimeout) - this.playTimeout = null - - if (this.challengeTimeout) { - clearTimeout(this.challengeTimeout) - this.challengeTimeout = null + /* Native-only: use 'data' + resume (flowing mode), push to ring buffer. + * No JS loops. The native pthread loop encrypts and sends. */ + rawStream.on('data', (chunk) => { + if (this._nativeSender) { + try { + let enc = chunk + if (this.mlsSession) { + const mlsEnc = this.mlsSession.encrypt(chunk) + if (mlsEnc) enc = mlsEnc + else return + } + const q = this._nativeSender.pushAudio(enc) + /* Backpressure: if queue is near-full, pause the stream + * to throttle the pipeline. The resume monitor will resume + * when the queue drains below the low-water mark (16). */ + if (q >= 42) { + this._backpressurePaused = true + rawStream.pause() + } + } catch (e) { + console.error('[VOICE] data error:', e.message) } + } + }) + rawStream.on('end', () => { + if (this._nativeSender) { + this._nativeSender.signalEndOfStream() + } + }) + rawStream.resume() + audioStream.once('readable', () => { + if (oldAudioStream) { this.statistics = { packetsSent: 0, packetsLost: 0, packetsExpected: 0 } } @@ -1875,23 +1686,15 @@ class Connection extends EventEmitter { stop(reason) { this._stopSilenceKeepalive() - this._clearNativeQueue() - if (this.playTimeout) { - clearTimeout(this.playTimeout) - this.playTimeout = null - } - - if (this.challengeTimeout) { - clearTimeout(this.challengeTimeout) - this.challengeTimeout = null + if (this._nativeSender) { + try { this._nativeSender.stopLoop() } catch (e) {} + this.player.sequence = this._nativeSender.sequence + this.player.timestamp = this._nativeSender.timestamp + this.nonce = this._nativeSender.nonce } if (this.audioStream) { - this.audioStream.removeListener( - 'finishBuffering', - this._boundMarkAsStoppable - ) this.audioStream.destroy() this.audioStream.removeAllListeners() this.audioStream = null @@ -1905,71 +1708,18 @@ class Connection extends EventEmitter { } pause(reason) { - this._clearNativeQueue() - this._updatePlayerState({ status: 'idle', reason: reason ?? 'paused' }) - if (this.playTimeout) { - clearTimeout(this.playTimeout) - this.playTimeout = null - } - - if (this.challengeTimeout) { - clearTimeout(this.challengeTimeout) - this.challengeTimeout = null + if (this._nativeSender) { + try { this._nativeSender.stopLoop() } catch (e) {} + this.player.sequence = this._nativeSender.sequence + this.player.timestamp = this._nativeSender.timestamp + this.nonce = this._nativeSender.nonce } this._sendSilenceFrames() } - _markAsStoppable() { - if (this.audioStream) this.audioStream.canStop = true - } - - _packetInterval() { - this.playTimeout = null - if (!this.audioStream) return - - const now = performance.now() - - const lateness = now - this.player.nextPacket - if (lateness > 100) { - this.player.nextPacket = now - } - - const chunk = this.audioStream.read?.(OPUS_FRAME_SIZE) - - if (chunk) { - this._clearChallengeTimeout() - this.sendAudioChunk(chunk) - } else if (this.audioStream.canStop) { - this._clearChallengeTimeout() - return this.stop('finished') - } else { - this.sendAudioChunk(OPUS_SILENCE_FRAME) - if (!this.challengeTimeout) { - this.challengeTimeout = setTimeout(() => { - this.challengeTimeout = null - this.emit('stuck') - this.pause('stuck') - }, this.stuckTimeout ?? 2000) - } - } - - this.player.nextPacket += OPUS_FRAME_DURATION - this.playTimeout = setTimeout( - this._boundPacketInterval, - Math.max(0, this.player.nextPacket - performance.now()) - ) - } - - _clearChallengeTimeout() { - if (this.challengeTimeout) { - clearTimeout(this.challengeTimeout) - this.challengeTimeout = null - } - } - unpause(reason) { if (this._silenceFrameTimeout) { clearTimeout(this._silenceFrameTimeout) @@ -1989,15 +1739,87 @@ class Connection extends EventEmitter { } } - this.player.nextPacket = now - this._packetInterval() + /* INFO: Native loop path */ + if (this.udp && this.udpInfo && this.udp._handle) { + try { + /* Sync counters from the old sender before destroying it, + * then create a fresh one with the latest counters */ + if (this._nativeSender) { + this.player.sequence = this._nativeSender.sequence + this.player.timestamp = this._nativeSender.timestamp + this.nonce = this._nativeSender.nonce + this._nativeSender.destroy() + this._nativeSender = null + } - if (!this.audioStream.canStop) { - this.audioStream.removeListener( - 'finishBuffering', - this._boundMarkAsStoppable - ) - this.audioStream.once('finishBuffering', this._boundMarkAsStoppable) + this._nativeSender = new NativeAudioSender( + this.encryption === 'aead_aes256_gcm_rtpsize' + ? NativeAudioSender.AES256_GCM + : NativeAudioSender.XCHACHA20, + this.udpInfo.secretKey, + this.udpInfo.ssrc, + this.player.sequence, + this.player.timestamp + ) + + this._nativeSender.resetNonce(this.nonce) + this._nativeSender.startLoop( + this.udp, + this.udpInfo.ip, + this.udpInfo.port + ) + + /* Register C→JS stats sync callback (replaces JS timer) */ + if (this.audioStream) { + const rawStream = this.audioStream.stream ?? this.audioStream + + /* Track whether WE paused the stream (for backpressure) */ + this._backpressurePaused = false + + rawStream.on('end', () => { + if (this._nativeSender) { + this._nativeSender.signalEndOfStream() + } + }) + + /* Register a C→JS callback that the native loop calls: + * 1) Every ~50 sends (≈1s) to sync statistics so the + * player's position advances (prevents stuck detection) + * 2) When the queue drains below LOW_WATER (16) to resume + * the stream after backpressure pauses it */ + if (this._nativeSender && this._nativeSender.setStatsCallback) { + this._nativeSender.setStatsCallback(() => { + if (!this._nativeSender) return + + /* Sync native → JS stats so player position advances */ + const st = this._nativeSender.statistics + this.statistics.packetsSent = st.sent + this.statistics.packetsLost = st.lost + this.statistics.packetsExpected = st.expected + this.player.lastPacketTime = performance.now() + + /* Resume stream if backpressure drained */ + if (this._backpressurePaused && !rawStream.destroyed) { + const q = this._nativeSender.queueCount + if (q <= 16) { + this._backpressurePaused = false + rawStream.resume() + } + } + }) + } + } + + return; + } catch (e) { + console.error('[VOICE] unpause: native loop failed:', e) + /* Native loop failed - clean up and propagate */ + if (this._nativeSender) { + try { this._nativeSender.destroy() } catch {} + this._nativeSender = null + } + throw new Error('Native audio loop failed to start: ' + e.message) + } } } @@ -2017,16 +1839,6 @@ class Connection extends EventEmitter { this._silenceFrameTimeout = null } - if (this.playTimeout) { - clearTimeout(this.playTimeout) - this.playTimeout = null - } - - if (this.challengeTimeout) { - clearTimeout(this.challengeTimeout) - this.challengeTimeout = null - } - if (this.connectTimeout) { clearTimeout(this.connectTimeout) this.connectTimeout = null @@ -2037,20 +1849,11 @@ class Connection extends EventEmitter { this.udpKeepAliveInterval = null } - if (this.audioStream) { - this.audioStream.removeListener( - 'finishBuffering', - this._boundMarkAsStoppable - ) - } - - if (this._drainInterval) { - clearInterval(this._drainInterval) - this._drainInterval = null - } - - if (this._nativeQueueKey !== null && this._nativeQueueManager) { - this._destroyNativeQueue() + if (this._nativeSender) { + try { + this._nativeSender.destroy() + } catch { /* ignore */ } + this._nativeSender = null } if (this.connectedUserIds) { @@ -2060,7 +1863,6 @@ class Connection extends EventEmitter { this.player = { sequence: 0, timestamp: 0, - nextPacket: 0, lastPacketTime: null } @@ -2112,8 +1914,6 @@ class Connection extends EventEmitter { this.ssrcs.clear() this._userIdToSSRCs.clear() - this._nativeQueueManager = null - this._updateState(state) this._updatePlayerState({ status: 'idle', reason: 'destroyed' }) } @@ -2141,7 +1941,7 @@ class Connection extends EventEmitter { code: 4014, closeReason: 'Voice server endpoint is null' }) - return + return; } if (channelId) { @@ -2152,7 +1952,7 @@ class Connection extends EventEmitter { this.voiceServer?.token === token && this.voiceServer?.endpoint === endpoint ) { - return + return; } this.voiceServer = { token, endpoint } diff --git a/native/binding.gyp b/native/binding.gyp new file mode 100644 index 0000000..23828dc --- /dev/null +++ b/native/binding.gyp @@ -0,0 +1,49 @@ +{ + "targets": [ + { + "target_name": "voice_send_native", + "sources": [ + "src/binding.c", + "src/voice_send.c", + "src/cthreads.c" + ], + "include_dirs": [ + "." + ], + "libraries": [ + "-lsodium", + "-lpthread" + ], + "conditions": [ + ["OS=='linux'", { + "cflags": [ + "-std=c99", + "-D_POSIX_C_SOURCE=200809L", + "-Wall", + "-Wextra", + "-Wpedantic" + ], + "ldflags": [] + }], + ["OS=='mac'", { + "xcode_settings": { + "OTHER_CFLAGS": [ + "-std=c99", + "-Wall", + "-Wextra", + "-Wpedantic", + ] + } + }], + ["OS=='win'", { + "libraries": [ + "-lsodium.lib" + ], + "include_dirs": [ + "src" + ] + }] + ] + } + ] +} diff --git a/native/index.js b/native/index.js new file mode 100644 index 0000000..5185438 --- /dev/null +++ b/native/index.js @@ -0,0 +1,129 @@ +import { createRequire } from 'node:module' + +let _native = null +let _encryptionMode = null + +const _require = createRequire(import.meta.url) +const mod = _require('./build/Release/voice_send_native.node') + +if (mod && typeof mod.createContext === 'function') { + _native = mod + _encryptionMode = mod.encryptionMode +} else { + throw new Error('Incomplete native module') +} + +class NativeAudioSender { + #ctx = null + + constructor(encryptionMode, secretKey, ssrc, sequence, timestamp) { + if (!_native) + throw new Error('Native module not available') + + this.#ctx = _native.createContext( + encryptionMode, + secretKey, + ssrc >>> 0, + (sequence >>> 0) & 0xffff, + timestamp >>> 0 + ) + if (!this.#ctx) + throw new Error('Failed to create native context') + } + + encrypt(audioChunk) { + if (!this.#ctx) return null + + return _native.encrypt(this.#ctx, audioChunk) + } + + startLoop(udpSocket, ip, port) { + if (!this.#ctx) throw new Error('Context destroyed') + + _native.startSendLoop(this.#ctx, udpSocket, ip, port) + } + + pushAudio(chunk) { + if (!this.#ctx) return 0 + + return _native.pushAudio(this.#ctx, chunk) >>> 0 + } + + signalEndOfStream() { + if (!this.#ctx) return; + + _native.pushAudio(this.#ctx, null) + } + + stopLoop() { + if (!this.#ctx) return; + + _native.stopSendLoop(this.#ctx) + } + + get sequence() { + if (!this.#ctx) return 0 + + return _native.getSequence(this.#ctx) + } + + get timestamp() { + if (!this.#ctx) return 0 + + return _native.getTimestamp(this.#ctx) + } + + get nonce() { + if (!this.#ctx) return 0 + + return _native.getNonce(this.#ctx) + } + + resetNonce(nonce) { + if (!this.#ctx) return; + + _native.resetNonce(this.#ctx, nonce >>> 0) + } + + get queueCount() { + if (!this.#ctx) return 0 + + return _native.getQueueCount(this.#ctx) >>> 0 + } + + get queueDropped() { + if (!this.#ctx) return 0 + + return _native.getQueueDropped(this.#ctx) >>> 0 + } + + get statistics() { + if (!this.#ctx) return { sent: 0, lost: 0, expected: 0 } + + return _native.getStatistics(this.#ctx) + } + + /* INFO: Register a JS callback to get periodic stats updates from the loop thread */ + setStatsCallback(fn) { + if (!this.#ctx) return; + + _native.setStatsCallback(this.#ctx, fn) + } + + destroy() { + if (!this.#ctx) return; + + try { + _native.destroyContext(this.#ctx) + } catch {} + + this.#ctx = null + } +} + +NativeAudioSender.AES256_GCM = _native ? _encryptionMode.AES256_GCM : 0 +NativeAudioSender.XCHACHA20 = _native ? _encryptionMode.XCHACHA20 : 1 + +export { _native as nativeModule, NativeAudioSender } + +export default NativeAudioSender diff --git a/native/src/binding.c b/native/src/binding.c new file mode 100644 index 0000000..5c38064 --- /dev/null +++ b/native/src/binding.c @@ -0,0 +1,827 @@ +#include + +#include +#include +#include + +#include "os_compat.h" +#include "cthreads.h" +#include "voice_send.h" +#include "utils.h" + +#define OPUS_FRAME_DURATION 20 + +#define AUDIO_QUEUE_SIZE 48 /* INFO: ~1 second of audio at 20ms frames */ +#define AUDIO_QUEUE_LOW 16 /* INFO: resume when below this */ + +struct audio_queue { + uint8_t data[AUDIO_QUEUE_SIZE][VOICE_MAX_PACKET_SIZE]; + size_t len[AUDIO_QUEUE_SIZE]; + unsigned head; /* INFO: read index (loop consumes from here) */ + unsigned tail; /* INFO: write index (JS pushes to here) */ + unsigned count; + unsigned dropped; +}; + +#define SEND_QUEUE_SIZE 256 + +struct send_queue { + uint8_t *data[SEND_QUEUE_SIZE]; + size_t len[SEND_QUEUE_SIZE]; + unsigned head; + unsigned tail; + unsigned count; +}; + +struct send_req_data { + struct native_ctx *nc; + uint8_t *buf; +}; + +struct native_ctx { + struct voice_send_ctx ctx; + + /* INFO: Audio queue and synchronisation */ + struct cthreads_mutex audio_mutex; + struct cthreads_cond audio_cond; + struct audio_queue audio_q; + + struct cthreads_thread loop_thread; + volatile int loop_running; + volatile int loop_eos; /* INFO: Enf-Of-Stream flag */ + uv_udp_t *udp_handle; + struct sockaddr_in udp_addr; + + uv_loop_t *loop; + uv_async_t async_handle; + int async_initialized; + + struct send_queue send_q; + + int pending_sends; + int final_cleanup_done; + + napi_threadsafe_function stats_tsfn; + int stats_counter; +}; + +/* INFO: Thread: audio send loop */ +static void *loop_thread_func(void *arg) { + struct native_ctx *nc = (struct native_ctx *)arg; + + uint8_t audio[VOICE_MAX_PACKET_SIZE]; + uint8_t packet[VOICE_MAX_PACKET_SIZE]; + long long next_send = os_now_ns(); + + while (nc->loop_running) { + size_t alen = 0; + int should_resume = 0; + + cthreads_mutex_lock(&nc->audio_mutex); + + /* INFO: Wait for audio data, or until it's time for the next packet */ + while (nc->audio_q.count == 0 && !nc->loop_eos && nc->loop_running) { + long long remaining = next_send - os_now_ns(); + if (remaining <= 0) break; /* time to send (silence or late) */ + + unsigned int wait_ms = (unsigned int)(remaining / 1000000LL); + if (wait_ms < 1) wait_ms = 1; + + /* If next_send is more than one frame ahead, the audio send burst + pushed it forward — reset to now and send immediately */ + if (wait_ms > OPUS_FRAME_DURATION) { + next_send = os_now_ns(); + + break; + } + + int ret = cthreads_cond_timedwait(&nc->audio_cond, &nc->audio_mutex, wait_ms); + if (ret != 0) break; /* timeout or error */ + } + + if (!nc->loop_running) { + cthreads_mutex_unlock(&nc->audio_mutex); + + break; + } + + if (nc->audio_q.count > 0) { + unsigned prev_count = nc->audio_q.count; + memcpy(audio, nc->audio_q.data[nc->audio_q.head], nc->audio_q.len[nc->audio_q.head]); + alen = nc->audio_q.len[nc->audio_q.head]; + nc->audio_q.head = (nc->audio_q.head + 1) % AUDIO_QUEUE_SIZE; + nc->audio_q.count--; + + /* INFO: If we drained below low water mark, signal main thread to resume stream */ + if (prev_count > AUDIO_QUEUE_LOW && nc->audio_q.count <= AUDIO_QUEUE_LOW) + should_resume = 1; + + cthreads_cond_signal(&nc->audio_cond); + } + + cthreads_mutex_unlock(&nc->audio_mutex); + + if (should_resume && nc->stats_tsfn != NULL) + napi_call_threadsafe_function(nc->stats_tsfn, NULL, napi_tsfn_nonblocking); + + if (nc->loop_eos && nc->audio_q.count == 0 && alen == 0) + break; + + /* INFO: Encrypt */ + int pkt_len; + if (alen > 0) + pkt_len = voice_send_encrypt(&nc->ctx, audio, alen, packet, sizeof(packet)); + else + pkt_len = voice_send_encrypt(&nc->ctx, OPUS_SILENCE_FRAME, OPUS_SILENCE_FRAME_SIZE, packet, sizeof(packet)); + + /* INFO: Send via UDP */ + if (pkt_len > 0) { + uint8_t *buf = (uint8_t *)malloc((size_t)pkt_len); + if (buf != NULL) { + memcpy(buf, packet, (size_t)pkt_len); + + cthreads_mutex_lock(&nc->audio_mutex); + if (nc->send_q.count < SEND_QUEUE_SIZE) { + nc->send_q.data[nc->send_q.tail] = buf; + nc->send_q.len[nc->send_q.tail] = (size_t)pkt_len; + nc->send_q.tail = (nc->send_q.tail + 1) % SEND_QUEUE_SIZE; + nc->send_q.count++; + } else { + free(nc->send_q.data[nc->send_q.head]); + nc->send_q.data[nc->send_q.head] = buf; + nc->send_q.len[nc->send_q.head] = (size_t)pkt_len; + nc->send_q.head = (nc->send_q.head + 1) % SEND_QUEUE_SIZE; + nc->send_q.tail = (nc->send_q.head + nc->send_q.count) % SEND_QUEUE_SIZE; + } + cthreads_mutex_unlock(&nc->audio_mutex); + + if (nc->async_initialized) + uv_async_send(&nc->async_handle); + } else { + nc->ctx.stats.packets_lost++; + } + } else { + nc->ctx.stats.packets_lost++; + } + + /* INFO: Schedule next packet exactly 20ms after this one */ + next_send += (long long)OPUS_FRAME_DURATION * 1000000LL; + } + + nc->loop_running = 0; + + return NULL; +} + +static void destroy_loop_fields(struct native_ctx *nc); + +static void try_final_cleanup(struct native_ctx *nc) { + if (nc->final_cleanup_done) return; + if (nc->pending_sends > 0) return; + + nc->final_cleanup_done = 1; + destroy_loop_fields(nc); + voice_send_destroy(&nc->ctx); + free(nc); +} + +static void on_udp_send(uv_udp_send_t *req, int status) { + struct send_req_data *d = (struct send_req_data *)req->data; + + if (d != NULL) { + struct native_ctx *nc = d->nc; + + if (status == 0) { + nc->ctx.stats.packets_sent++; + nc->stats_counter++; + if (nc->stats_tsfn != NULL && nc->stats_counter >= 50) { + nc->stats_counter = 0; + napi_call_threadsafe_function(nc->stats_tsfn, NULL, napi_tsfn_nonblocking); + } + } else { + nc->ctx.stats.packets_lost++; + } + + free(d->buf); + free(d); + + nc->pending_sends--; + try_final_cleanup(nc); + } + + free(req); +} + +static void async_send_cb(uv_async_t *handle) { + struct native_ctx *nc = (struct native_ctx *)handle->data; + if (nc == NULL || nc->udp_handle == NULL) return; + + while (1) { + uint8_t *buf = NULL; + size_t len = 0; + + cthreads_mutex_lock(&nc->audio_mutex); + if (nc->send_q.count > 0) { + buf = nc->send_q.data[nc->send_q.head]; + len = nc->send_q.len[nc->send_q.head]; + nc->send_q.head = (nc->send_q.head + 1) % SEND_QUEUE_SIZE; + nc->send_q.count--; + } + cthreads_mutex_unlock(&nc->audio_mutex); + + if (buf == NULL) break; + + uv_udp_send_t *req = (uv_udp_send_t *)malloc(sizeof(uv_udp_send_t)); + if (req == NULL) { + /* TODO: pass env to use napi throw error...? */ + fprintf(stderr, "Failed to allocate uv_udp_send_t\n"); + + free(buf); + + nc->ctx.stats.packets_lost++; + + continue; + } + + struct send_req_data *data = (struct send_req_data *)malloc(sizeof(struct send_req_data)); + if (data == NULL) { + /* TODO: pass env to use napi throw error...? */ + fprintf(stderr, "Failed to allocate send_req_data\n"); + + free(buf); + free(req); + + nc->ctx.stats.packets_lost++; + + continue; + } + + data->nc = nc; + data->buf = buf; + req->data = data; + + /* TODO: Change length from queue to unsigned int to match? */ + uv_buf_t uv_buf = uv_buf_init((char *)buf, (unsigned int)len); + nc->pending_sends++; + if (uv_udp_send(req, nc->udp_handle, &uv_buf, 1, (const struct sockaddr *)&nc->udp_addr, on_udp_send) != 0) { + free(data->buf); + free(data); + free(req); + + nc->pending_sends--; + nc->ctx.stats.packets_lost++; + } + } +} + +static int init_loop_fields(struct native_ctx *nc) { + int ret = cthreads_mutex_init(&nc->audio_mutex, NULL); + if (ret != 0) return -1; + + ret = cthreads_cond_init(&nc->audio_cond, NULL); + if (ret != 0) { + cthreads_mutex_destroy(&nc->audio_mutex); + + return -1; + } + + return 0; +} + +static void destroy_loop_fields(struct native_ctx *nc) { + cthreads_mutex_destroy(&nc->audio_mutex); + cthreads_cond_destroy(&nc->audio_cond); +} + +/* INFO: Safely stop the audio loop and wait for it to finish */ +static void stop_loop_and_join(struct native_ctx *nc) { + if (!nc->loop_running) return; + + nc->loop_running = 0; + cthreads_mutex_lock(&nc->audio_mutex); + cthreads_cond_signal(&nc->audio_cond); + cthreads_mutex_unlock(&nc->audio_mutex); + cthreads_thread_join(nc->loop_thread, NULL); +} + +static void close_async_and_finish(struct native_ctx *nc) { + if (nc->stats_tsfn != NULL) { + napi_release_threadsafe_function(nc->stats_tsfn, napi_tsfn_abort); + nc->stats_tsfn = NULL; + } + + cthreads_mutex_lock(&nc->audio_mutex); + while (nc->send_q.count > 0) { + free(nc->send_q.data[nc->send_q.head]); + nc->send_q.head = (nc->send_q.head + 1) % SEND_QUEUE_SIZE; + nc->send_q.count--; + } + cthreads_mutex_unlock(&nc->audio_mutex); + + if (nc->async_initialized) { + nc->async_handle.data = nc; + uv_close((uv_handle_t *)&nc->async_handle, (uv_close_cb)try_final_cleanup); + } else { + destroy_loop_fields(nc); + voice_send_destroy(&nc->ctx); + free(nc); + } +} + +/* INFO: Finalizer called when the JS wrapper is GC'd */ +static void finalize_native_ctx(napi_env env, void *data, void *hint) { + (void)env; (void)hint; + + struct native_ctx *nc = (struct native_ctx *)data; + + if (nc != NULL) { + stop_loop_and_join(nc); + close_async_and_finish(nc); + } +} + +static napi_status unwrap_ctx(napi_env env, napi_value val, struct native_ctx **out) { + return napi_unwrap(env, val, (void **)out); +} + +/* INFO: createContext(encryption, key, ssrc, seq, ts) */ +static napi_value create_context(napi_env env, napi_callback_info info) { + NAPI_GET_CB_INFO(5) + + int encryption; + NAPI_GET_VALUE(encryption, 0, int32) + + void *key_data; + size_t key_len; + status = napi_get_buffer_info(env, args[1], &key_data, &key_len); + if (status != napi_ok || key_len < 32) { + napi_throw_type_error(env, NULL, "key must be a Buffer of at least 32 bytes"); + + return NULL; + } + + uint32_t ssrc; + NAPI_GET_VALUE(ssrc, 2, uint32) + + uint32_t seq; + NAPI_GET_VALUE(seq, 3, uint32) + + uint32_t ts; + NAPI_GET_VALUE(ts, 4, uint32) + + struct native_ctx *nc = (struct native_ctx *)calloc(1, sizeof(struct native_ctx)); + if (nc == NULL) { + napi_throw_error(env, NULL, "Out of memory"); + + return NULL; + } + + if (init_loop_fields(nc) != 0) { + napi_throw_error(env, NULL, "Failed to initialise synchronisation primitives"); + + goto cleanup_nc_fail; + } + + if (voice_send_init(&nc->ctx, (enum voice_encryption_mode)encryption, (const uint8_t *)key_data, ssrc, (uint16_t)(seq & 0xFFFF), ts) == NULL) { + napi_throw_error(env, NULL, "voice_send_init failed"); + + goto cleanup_fields_fail; + } + + napi_value result; + status = napi_create_object(env, &result); + if (status != napi_ok) { + napi_throw_error(env, NULL, "napi_create_object failed"); + + goto cleanup_all_fail; + } + + status = napi_wrap(env, result, (void *)nc, finalize_native_ctx, NULL, NULL); + if (status != napi_ok) { + napi_throw_error(env, NULL, "napi_wrap failed"); + + goto cleanup_all_fail; + } + + return result; + + cleanup_all_fail: + voice_send_destroy(&nc->ctx); + cleanup_fields_fail: + destroy_loop_fields(nc); + cleanup_nc_fail: + free(nc); + + return NULL; +} + +/* INFO: startSendLoop(ctx, udpSocket, ip, port) */ +static napi_value start_send_loop(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(4) + + if (nc->loop_running) { + napi_throw_error(env, NULL, "Loop is already running"); + + return NULL; + } + + napi_value handle_val; + status = napi_get_named_property(env, args[1], "_handle", &handle_val); + if (status != napi_ok) { + napi_throw_type_error(env, NULL, "First argument must be a dgram.Socket with a _handle"); + + return NULL; + } + + napi_value external_val; + status = napi_get_named_property(env, handle_val, "_external", &external_val); + if (status != napi_ok) { + napi_throw_type_error(env, NULL, "_handle is missing _external"); + + return NULL; + } + + uv_udp_t *udp_handle; + status = napi_get_value_external(env, external_val, (void **)&udp_handle); + if (status != napi_ok || udp_handle == NULL) { + napi_throw_type_error(env, NULL, "Failed to extract uv_udp_t pointer from _external"); + + return NULL; + } + + char ip_str[64]; + size_t ip_len; + status = napi_get_value_string_utf8(env, args[2], ip_str, sizeof(ip_str), &ip_len); + if (status != napi_ok || ip_len == 0) { + napi_throw_type_error(env, NULL, "ip must be a string"); + + return NULL; + } + + int32_t port; + NAPI_GET_VALUE_EXTRA(port, 3, int32, port > 0 && port <= 65535, "a valid port number") + + nc->udp_handle = udp_handle; + memset(&nc->udp_addr, 0, sizeof(nc->udp_addr)); + nc->udp_addr.sin_family = AF_INET; + nc->udp_addr.sin_port = htons((uint16_t)port); + if (os_inet_pton(ip_str, &nc->udp_addr.sin_addr) <= 0) { + napi_throw_type_error(env, NULL, "Invalid IP address"); + + return NULL; + } + + if (!nc->async_initialized) { + nc->loop = uv_default_loop(); + nc->async_handle.data = nc; + if (uv_async_init(nc->loop, &nc->async_handle, async_send_cb) != 0) { + napi_throw_error(env, NULL, "uv_async_init failed"); + + return NULL; + } + nc->async_initialized = 1; + } + + nc->loop_running = 1; + { + struct cthreads_args thr_args; + thr_args.func = loop_thread_func; + thr_args.data = nc; + if (cthreads_thread_create(&nc->loop_thread, NULL, loop_thread_func, nc, &thr_args) != 0) { + napi_throw_error(env, NULL, "Failed to create loop thread"); + + nc->loop_running = 0; + + return NULL; + } + } + + napi_get_undefined(env, &args[0]); + + return args[0]; +} + +/* INFO: pushAudio(ctx, chunk) */ +static napi_value push_audio(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(2) + + /* INFO: Handle both Buffer (non-empty) and null (end-of-stream) */ + napi_valuetype t; + napi_typeof(env, args[1], &t); + if (t == napi_null || t == napi_undefined) { + cthreads_mutex_lock(&nc->audio_mutex); + + nc->loop_eos = 1; + cthreads_cond_signal(&nc->audio_cond); + + cthreads_mutex_unlock(&nc->audio_mutex); + + napi_get_undefined(env, &args[0]); + + return args[0]; + } + + void *chunk_data; + size_t chunk_len; + status = napi_get_buffer_info(env, args[1], &chunk_data, &chunk_len); + if (status != napi_ok || chunk_data == NULL || chunk_len == 0) { + napi_throw_type_error(env, NULL, "chunk must be a non-empty Buffer or null"); + + return NULL; + } + + /* INFO: Ring buffer push with backpressure hint + + Non-blocking push. Returns the count of items remaining in the + queue AFTER the push. This tells JS whether to pause the stream: + if count >= HIGH_WATER (42/48), the JS 'data' handler should + call rawStream.pause() to create backpressure through the pipeline. + When the loop drains below LOW_WATER (16/48), the JS 'data' handler + should call rawStream.resume(). + + This avoids blocking the event loop entirely while still providing + natural backpressure. + */ + cthreads_mutex_lock(&nc->audio_mutex); + + /* If queue is entirely full, drop oldest as emergency fallback */ + if (nc->audio_q.count >= AUDIO_QUEUE_SIZE) { + nc->audio_q.head = (nc->audio_q.head + 1) % AUDIO_QUEUE_SIZE; + nc->audio_q.count--; + nc->audio_q.dropped++; + } + + size_t copy_len = chunk_len; + if (copy_len > VOICE_MAX_PACKET_SIZE) copy_len = VOICE_MAX_PACKET_SIZE; + + memcpy(nc->audio_q.data[nc->audio_q.tail], chunk_data, copy_len); + nc->audio_q.len[nc->audio_q.tail] = copy_len; + nc->audio_q.tail = (nc->audio_q.tail + 1) % AUDIO_QUEUE_SIZE; + nc->audio_q.count++; + + unsigned after_push = nc->audio_q.count; + + cthreads_cond_signal(&nc->audio_cond); + + cthreads_mutex_unlock(&nc->audio_mutex); + + /* INFO: Return the queue count after push so JS can manage backpressure */ + napi_create_uint32(env, after_push, &args[0]); + + return args[0]; +} + +/* INFO: stopSendLoop(ctx) */ +static napi_value stop_send_loop(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(1) + + stop_loop_and_join(nc); + + cthreads_mutex_lock(&nc->audio_mutex); + while (nc->send_q.count > 0) { + free(nc->send_q.data[nc->send_q.head]); + nc->send_q.head = (nc->send_q.head + 1) % SEND_QUEUE_SIZE; + nc->send_q.count--; + } + cthreads_mutex_unlock(&nc->audio_mutex); + + napi_get_undefined(env, &args[0]); + + return args[0]; +} + +/* INFO: setStatsCallback(ctx, fn) */ +static napi_value set_stats_callback(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(2) + + napi_valuetype t; + napi_typeof(env, args[1], &t); + if (t != napi_function) { + napi_throw_type_error(env, NULL, "Second argument must be a function"); + + return NULL; + } + + /* INFO: If we already have a TSFN, release it first */ + if (nc->stats_tsfn != NULL) { + napi_release_threadsafe_function(nc->stats_tsfn, napi_tsfn_abort); + nc->stats_tsfn = NULL; + } + + /* INFO: Async resource name */ + napi_value resource_name; + napi_create_string_utf8(env, "voice-stats-sync", NAPI_AUTO_LENGTH, &resource_name); + + status = napi_create_threadsafe_function( + env, + args[1], /* func */ + NULL, /* async_resource */ + resource_name, /* async_resource_name */ + 0, /* max_queue_size (unlimited) */ + 1, /* initial_thread_count */ + NULL, /* thread_finalize_data */ + NULL, /* thread_finalize_cb */ + (void *)nc, /* context */ + NULL, /* call_js_cb (direct call) */ + &nc->stats_tsfn + ); + if (status != napi_ok) { + nc->stats_tsfn = NULL; + napi_throw_error(env, NULL, "Failed to create threadsafe function"); + + return NULL; + } + + nc->stats_counter = 0; + + napi_get_undefined(env, &args[0]); + + return args[0]; +} + +/* INFO: encrypt(context, audioChunk): Buffer */ +static napi_value encrypt(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(2) + + void *chunk_data; + size_t chunk_len; + status = napi_get_buffer_info(env, args[1], &chunk_data, &chunk_len); + if (status != napi_ok || chunk_data == NULL || chunk_len == 0) { + napi_throw_type_error(env, NULL, "audioChunk must be a non-empty Buffer"); + + return NULL; + } + + uint8_t packet[VOICE_MAX_PACKET_SIZE]; + int pkt_len = voice_send_encrypt(&nc->ctx, (const uint8_t *)chunk_data, chunk_len, packet, sizeof(packet)); + if (pkt_len < 0) { + napi_value result; + napi_get_null(env, &result); + + return result; + } + + napi_value result; + status = napi_create_buffer_copy(env, (size_t)pkt_len, packet, NULL, &result); + if (status != napi_ok) { + napi_throw_error(env, NULL, "napi_create_buffer_copy failed"); + + return NULL; + } + + return result; +} + +/* INFO: destroyContext(context) */ +static napi_value destroy_context(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(1) + + stop_loop_and_join(nc); + + status = napi_remove_wrap(env, args[0], (void **)&nc); + if (status == napi_ok && nc != NULL) { + close_async_and_finish(nc); + } + + napi_get_undefined(env, &args[0]); + + return args[0]; +} + +static inline napi_value get_sequence(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(1) + + napi_value result; + napi_create_uint32(env, voice_send_sequence(&nc->ctx), &result); + + return result; +} + +static inline napi_value get_timestamp(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(1) + + napi_value result; + napi_create_uint32(env, voice_send_timestamp(&nc->ctx), &result); + + return result; +} + +static inline napi_value get_nonce(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(1) + + napi_value result; + napi_create_uint32(env, voice_send_nonce(&nc->ctx), &result); + + return result; +} + +static napi_value reset_nonce(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(2) + + uint32_t nonce; + NAPI_GET_VALUE(nonce, 1, uint32) + + voice_send_reset_nonce(&nc->ctx, nonce); + + napi_get_undefined(env, &args[0]); + + return args[0]; +} + +/* INFO: getQueueCount(ctx) */ +static napi_value get_queue_count(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(1) + + cthreads_mutex_lock(&nc->audio_mutex); + unsigned count = nc->audio_q.count; + cthreads_mutex_unlock(&nc->audio_mutex); + + napi_value result; + napi_create_uint32(env, count, &result); + + return result; +} + +/* INFO: getQueueDropped(ctx) */ +static napi_value get_queue_dropped(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(1) + + cthreads_mutex_lock(&nc->audio_mutex); + unsigned dropped = nc->audio_q.dropped; + cthreads_mutex_unlock(&nc->audio_mutex); + + napi_value result; + napi_create_uint32(env, dropped, &result); + + return result; +} + +/* INFO: getStatistics(ctx): { sent, lost, expected } */ +static napi_value get_statistics(napi_env env, napi_callback_info info) { + NAPI_INIT_FN_INFO(1) + + struct voice_send_stats st = voice_send_stats(&nc->ctx); + + napi_value result; + status = napi_create_object(env, &result); + if (status != napi_ok) return NULL; + + napi_value v; + napi_create_uint32(env, (uint32_t)st.packets_sent, &v); + napi_set_named_property(env, result, "sent", v); + + napi_create_uint32(env, (uint32_t)st.packets_lost, &v); + napi_set_named_property(env, result, "lost", v); + + napi_create_uint32(env, (uint32_t)st.packets_expected, &v); + napi_set_named_property(env, result, "expected", v); + + return result; +} + +/* INFO: Module exports */ + +#define EXPORT(name, func) \ + status = napi_create_function(env, name, NAPI_AUTO_LENGTH, func, NULL, &fn); \ + if (status != napi_ok) return NULL; \ + \ + status = napi_set_named_property(env, exports, name, fn); \ + if (status != napi_ok) return NULL; + +static napi_value init_module(napi_env env, napi_value exports) { + napi_status status; + napi_value fn; + + EXPORT("createContext", create_context) + EXPORT("encrypt", encrypt) + EXPORT("destroyContext", destroy_context) + EXPORT("getSequence", get_sequence) + EXPORT("getTimestamp", get_timestamp) + EXPORT("getNonce", get_nonce) + EXPORT("resetNonce", reset_nonce) + EXPORT("startSendLoop", start_send_loop) + EXPORT("pushAudio", push_audio) + EXPORT("stopSendLoop", stop_send_loop) + EXPORT("getQueueCount", get_queue_count) + EXPORT("getQueueDropped", get_queue_dropped) + EXPORT("getStatistics", get_statistics) + EXPORT("setStatsCallback", set_stats_callback) + + napi_value mode; + napi_create_object(env, &mode); + + napi_value v; + napi_create_int32(env, VOICE_ENCRYPTION_AES256_GCM, &v); + napi_set_named_property(env, mode, "AES256_GCM", v); + + napi_create_int32(env, VOICE_ENCRYPTION_XCHACHA20, &v); + napi_set_named_property(env, mode, "XCHACHA20", v); + + napi_set_named_property(env, exports, "encryptionMode", mode); + + return exports; +} + +#undef EXPORT + +NAPI_MODULE(NODE_GYP_MODULE_NAME, init_module) diff --git a/native/src/cthreads.c b/native/src/cthreads.c new file mode 100644 index 0000000..d51305c --- /dev/null +++ b/native/src/cthreads.c @@ -0,0 +1,697 @@ +#include +#include +#include + +#ifndef _WIN32 + #include /* errno */ + #include /* strerror(), strlen() */ +#endif + +#include "cthreads.h" + +#ifdef _WIN32 +#include +DWORD WINAPI __cthreads_winthreads_function_wrapper(void *data) { + struct cthreads_args *args = data; + return (DWORD)(uintptr_t)args->func(args->data); +} +#else +#include +#endif + +int cthreads_thread_create(struct cthreads_thread *thread, struct cthreads_thread_attr *attr, void *(*func)(void *data), void *data, struct cthreads_args *args) { + #ifdef CTHREADS_DEBUG + puts("cthreads_thread_create"); + #endif + + /* INFO: POSIX path may not use it, but we must guarantee symmetry */ + if (!args) return 1; + + #ifdef _WIN32 + args->func = func; + args->data = data; + + DWORD tid; + if (attr) { + thread->wThread = CreateThread(NULL, attr->stacksize ? attr->stacksize : 0, + __cthreads_winthreads_function_wrapper, args, + attr->dwCreationFlags ? (DWORD)attr->dwCreationFlags : 0, &tid); + } else { + thread->wThread = CreateThread(NULL, 0, __cthreads_winthreads_function_wrapper, args, 0, &tid); + } + + /* INFO: If successful, write tid for later access */ + if (thread->wThread) thread->wThreadId = tid; + + return thread->wThread == NULL; + #else + pthread_attr_t pAttr; + if (attr) { + if (pthread_attr_init(&pAttr)) return 1; + + int ret = 0; + if (attr->detachstate) ret = pthread_attr_setdetachstate(&pAttr, attr->detachstate); + if (ret == 0 && attr->guardsize) ret = pthread_attr_setguardsize(&pAttr, attr->guardsize); + #ifdef CTHREADS_THREAD_INHERITSCHED + if (ret == 0 && attr->inheritsched) ret = pthread_attr_setinheritsched(&pAttr, attr->inheritsched); + #endif + if (ret == 0 && attr->schedpolicy) ret = pthread_attr_setschedpolicy(&pAttr, attr->schedpolicy); + if (ret == 0 && attr->scope) ret = pthread_attr_setscope(&pAttr, attr->scope); + #ifdef CTHREADS_THREAD_STACK + if (ret == 0 && attr->stack) ret = pthread_attr_setstack(&pAttr, attr->stackaddr, attr->stack); + /* INFO: Using both _setstack and _setstacksize is disallowed by POSIX */ + #ifdef CTHREADS_THREAD_STACKADDR + else + #endif + #endif + #ifdef CTHREADS_THREAD_STACKADDR + if (ret == 0 && attr->stacksize) ret = pthread_attr_setstacksize(&pAttr, attr->stacksize); + #endif + + if (ret) { + pthread_attr_destroy(&pAttr); + + return 1; + } + } + + int ret = pthread_create(&thread->pThread, attr ? &pAttr : NULL, func, data); + if (attr) pthread_attr_destroy(&pAttr); + + return ret; + #endif +} + +int cthreads_thread_detach(struct cthreads_thread thread) { + #ifdef CTHREADS_DEBUG + puts("cthreads_thread_detach"); + #endif + + #ifdef _WIN32 + return CloseHandle(thread.wThread) == 0; + #else + return pthread_detach(thread.pThread); + #endif +} + +int cthreads_thread_join(struct cthreads_thread thread, void *code) { + #ifdef CTHREADS_DEBUG + puts("cthreads_thread_join"); + #endif + + #ifdef _WIN32 + HANDLE handle = thread.wThread; + int opened = 0; + + if (!handle) { + handle = OpenThread(SYNCHRONIZE | THREAD_QUERY_INFORMATION, FALSE, thread.wThreadId); + if (!handle) return 1; + + opened = 1; + } + + if (WaitForSingleObject(handle, INFINITE) == WAIT_FAILED) { + if (opened) CloseHandle(handle); + + return 1; + } + + DWORD exit_code; + if (!GetExitCodeThread(handle, (LPDWORD)&exit_code)) { + if (opened) CloseHandle(handle); + + return 1; + } + + CloseHandle(handle); + + if (code) *(void **)code = (void *)(uintptr_t)exit_code; + + return cthreads_thread_detach(thread); + #else + return pthread_join(thread.pThread, code ? (void **)code : NULL); + #endif +} + +int cthreads_thread_equal(struct cthreads_thread thread1, struct cthreads_thread thread2) { + #ifdef CTHREADS_DEBUG + puts("cthreads_thread_equal"); + #endif + + #ifdef _WIN32 + return thread1.wThreadId == thread2.wThreadId; + #else + return pthread_equal(thread1.pThread, thread2.pThread); + #endif +} + +struct cthreads_thread cthreads_thread_self(void) { + struct cthreads_thread t; + + #ifdef CTHREADS_DEBUG + puts("cthreads_thread_self"); + #endif + + #ifdef _WIN32 + /* INFO: No real handle, only the ID. */ + t.wThread = NULL; + t.wThreadId = GetCurrentThreadId(); + #else + t.pThread = pthread_self(); + #endif + + return t; +} + +/* + INFO: This is a best-effort implementation on POSIX systems. There is no + reliable way to get the thread ID, as pthread_t is an opaque type. + + On Windows we return the stable thread ID stored at creation. +*/ +unsigned long cthreads_thread_id(struct cthreads_thread thread) { + #ifdef CTHREADS_DEBUG + puts("cthreads_thread_id"); + #endif + + #ifdef _WIN32 + return (unsigned long)thread.wThreadId; + #else + return (unsigned long)(uintptr_t)thread.pThread; + #endif +} + +void cthreads_thread_exit(void *code) { + #ifdef CTHREADS_DEBUG + puts("cthreads_thread_exit"); + #endif + + #ifdef _WIN32 + /* INFO: On Windows 64-bit, we cannot losslessly convert a pointer to a DWORD */ + ExitThread((DWORD)(uintptr_t)code); + #else + pthread_exit(code); + #endif +} + +int cthreads_thread_cancel(struct cthreads_thread thread) { + #ifdef CTHREADS_DEBUG + puts("cthreads_thread_cancel"); + #endif + + #ifdef _WIN32 + HANDLE handle = thread.wThread; + int opened = 0; + + if (!handle) { + handle = OpenThread(THREAD_TERMINATE, FALSE, thread.wThreadId); + if (!handle) return 1; + + opened = 1; + } + + BOOL ret = TerminateThread(handle, 0); + if (opened) CloseHandle(handle); + + return ret == 0; + #else + return pthread_cancel(thread.pThread); + #endif +} + +#ifdef CTHREADS_MUTEX_ATTR + int cthreads_mutex_init(struct cthreads_mutex *mutex, struct cthreads_mutex_attr *attr) { +#else + int cthreads_mutex_init(struct cthreads_mutex *mutex, void *attr) { +#endif + #ifdef _WIN32 + #ifdef CTHREADS_DEBUG + puts("cthreads_mutex_init"); + #endif + + (void) attr; + + InitializeCriticalSection(&mutex->wMutex); + + return 0; + #else + pthread_mutexattr_t pAttr; + + #ifdef CTHREADS_DEBUG + puts("cthreads_mutex_init"); + #endif + + /* CTHREADS_MUTEX_ATTR is always available on non-Windows platforms */ + if (attr) { + if (pthread_mutexattr_init(&pAttr)) return 1; + + int ret = 0; + if (attr->pshared) ret = pthread_mutexattr_setpshared(&pAttr, attr->pshared); + #ifdef CTHREADS_MUTEX_TYPE + if (ret == 0 && attr->type) ret = pthread_mutexattr_settype(&pAttr, attr->type); + #endif + #ifdef CTHREADS_MUTEX_ROBUST + if (ret == 0 && attr->robust) ret = pthread_mutexattr_setrobust(&pAttr, attr->robust); + #endif + #ifdef CTHREADS_MUTEX_PROTOCOL + if (ret == 0 && attr->protocol) ret = pthread_mutexattr_setprotocol(&pAttr, attr->protocol); + #endif + #ifdef CTHREADS_MUTEX_PRIOCEILING + if (ret == 0 && attr->prioceiling) ret = pthread_mutexattr_setprioceiling(&pAttr, attr->prioceiling); + #endif + + if (ret) { + pthread_mutexattr_destroy(&pAttr); + + return 1; + } + } + + int ret = pthread_mutex_init(&mutex->pMutex, attr ? &pAttr : NULL); + if (attr) pthread_mutexattr_destroy(&pAttr); + + return ret; + #endif +} + +int cthreads_mutex_lock(struct cthreads_mutex *mutex) { + #ifdef CTHREADS_DEBUG + puts("cthreads_mutex_lock"); + #endif + + #ifdef _WIN32 + EnterCriticalSection(&mutex->wMutex); + + return 0; + #else + return pthread_mutex_lock(&mutex->pMutex); + #endif +} + +int cthreads_mutex_trylock(struct cthreads_mutex *mutex) { + #ifdef CTHREADS_DEBUG + puts("cthreads_mutex_trylock"); + #endif + + #ifdef _WIN32 + return TryEnterCriticalSection(&mutex->wMutex) == 0; + #else + return pthread_mutex_trylock(&mutex->pMutex); + #endif +} + +int cthreads_mutex_unlock(struct cthreads_mutex *mutex) { + #ifdef CTHREADS_DEBUG + puts("cthreads_mutex_unlock"); + #endif + + #ifdef _WIN32 + LeaveCriticalSection(&mutex->wMutex); + + return 0; + #else + return pthread_mutex_unlock(&mutex->pMutex); + #endif +} + +int cthreads_mutex_destroy(struct cthreads_mutex *mutex) { + #ifdef CTHREADS_DEBUG + puts("cthreads_mutex_destroy"); + #endif + + #ifdef _WIN32 + DeleteCriticalSection(&mutex->wMutex); + + return 0; + #else + return pthread_mutex_destroy(&mutex->pMutex); + #endif +} + +#ifdef CTHREADS_COND_ATTR + int cthreads_cond_init(struct cthreads_cond *cond, struct cthreads_cond_attr *attr) { +#else + int cthreads_cond_init(struct cthreads_cond *cond, void *attr) { +#endif + #ifdef CTHREADS_DEBUG + puts("cthreads_cond_init"); + #endif + + #ifdef _WIN32 + (void) attr; + + InitializeConditionVariable(&cond->wCond); + + return 0; + #else + pthread_condattr_t pAttr; + + /* CTHREADS_COND_ATTR is always available on non-Windows platforms */ + if (attr) { + if (pthread_condattr_init(&pAttr) != 0) return 1; + + int ret = 0; + if (attr->pshared) ret = pthread_condattr_setpshared(&pAttr, attr->pshared); + #ifdef CTHREADS_COND_CLOCK + if (ret == 0 && attr->clock) ret = pthread_condattr_setclock(&pAttr, attr->clock); + #endif + + if (ret) { + pthread_condattr_destroy(&pAttr); + + return 1; + } + } + + #ifdef CTHREADS_COND_CLOCK + cond->clock = (attr && attr->clock) ? attr->clock : CLOCK_REALTIME; + #endif + + int ret = pthread_cond_init(&cond->pCond, attr ? &pAttr : NULL); + if (attr) pthread_condattr_destroy(&pAttr); + + return ret; + #endif +} + +int cthreads_cond_signal(struct cthreads_cond *cond) { + #ifdef CTHREADS_DEBUG + puts("cthreads_cond_signal"); + #endif + + #ifdef _WIN32 + WakeConditionVariable(&cond->wCond); + + return 0; + #else + return pthread_cond_signal(&cond->pCond); + #endif +} + +int cthreads_cond_broadcast(struct cthreads_cond *cond) { + #ifdef CTHREADS_DEBUG + puts("cthreads_cond_broadcast"); + #endif + + #ifdef _WIN32 + WakeAllConditionVariable(&cond->wCond); + + return 0; + #else + return pthread_cond_broadcast(&cond->pCond); + #endif +} + +int cthreads_cond_destroy(struct cthreads_cond *cond) { + #ifdef CTHREADS_DEBUG + puts("cthreads_cond_destroy"); + #endif + + #ifdef _WIN32 + return 0; + #else + return pthread_cond_destroy(&cond->pCond); + #endif +} + +int cthreads_cond_wait(struct cthreads_cond *cond, struct cthreads_mutex *mutex) { + #ifdef CTHREADS_DEBUG + puts("cthreads_cond_wait"); + #endif + + #ifdef _WIN32 + return SleepConditionVariableCS(&cond->wCond, &mutex->wMutex, INFINITE) == 0; + #else + return pthread_cond_wait(&cond->pCond, &mutex->pMutex); + #endif +} + +int cthreads_cond_timedwait(struct cthreads_cond *cond, struct cthreads_mutex *mutex, unsigned int ms) { + #ifdef CTHREADS_DEBUG + puts("cthreads_cond_wait"); + #endif + + #ifdef _WIN32 + return SleepConditionVariableCS(&cond->wCond, &mutex->wMutex, (DWORD)ms) == 0; + #else + struct timespec ts; + #ifdef CTHREADS_COND_CLOCK + if (clock_gettime(cond->clock, &ts)) return 1; + #else + if (clock_gettime(CLOCK_REALTIME, &ts)) return 1; + #endif + + ts.tv_sec += ms / 1000; + ts.tv_nsec += (ms % 1000) * 1000000; + ts.tv_sec += ts.tv_nsec / 1000000000; + ts.tv_nsec %= 1000000000; + + return pthread_cond_timedwait(&cond->pCond, &mutex->pMutex, &ts); + #endif +} + +#ifdef CTHREADS_RWLOCK + int cthreads_rwlock_init(struct cthreads_rwlock *rwlock) { + #ifdef CTHREADS_DEBUG + puts("cthreads_rwlock_init"); + #endif + + #ifdef _WIN32 + rwlock->wRWLock = malloc(sizeof(SRWLOCK)); + if (!rwlock->wRWLock) return 1; + + InitializeSRWLock(rwlock->wRWLock); + + return 0; + #else + return pthread_rwlock_init(&rwlock->pRWLock, NULL); + #endif + } + + int cthreads_rwlock_rdlock(struct cthreads_rwlock *rwlock) { + #ifdef CTHREADS_DEBUG + puts("cthreads_rwlock_rdlock"); + #endif + + #ifdef _WIN32 + AcquireSRWLockShared(rwlock->wRWLock); + + return 0; + #else + return pthread_rwlock_rdlock(&rwlock->pRWLock); + #endif + } + + int cthreads_rwlock_unlock_shared(struct cthreads_rwlock *rwlock) { + #ifdef CTHREADS_DEBUG + puts("cthreads_rwlock_unlock_shared"); + #endif + + #ifdef _WIN32 + ReleaseSRWLockShared(rwlock->wRWLock); + + return 0; + #else + return pthread_rwlock_unlock(&rwlock->pRWLock); + #endif + } + + int cthreads_rwlock_unlock_exclusive(struct cthreads_rwlock *rwlock) { + #ifdef CTHREADS_DEBUG + puts("cthreads_rwlock_unlock_exclusive"); + #endif + + #ifdef _WIN32 + ReleaseSRWLockExclusive(rwlock->wRWLock); + + return 0; + #else + return pthread_rwlock_unlock(&rwlock->pRWLock); + #endif + } + + int cthreads_rwlock_wrlock(struct cthreads_rwlock *rwlock) { + #ifdef CTHREADS_DEBUG + puts("cthreads_rwlock_wrlock"); + #endif + + #ifdef _WIN32 + AcquireSRWLockExclusive(rwlock->wRWLock); + + return 0; + #else + return pthread_rwlock_wrlock(&rwlock->pRWLock); + #endif + } + + int cthreads_rwlock_destroy(struct cthreads_rwlock *rwlock) { + #ifdef CTHREADS_DEBUG + puts("cthreads_rwlock_destroy"); + #endif + + #ifdef _WIN32 + free(rwlock->wRWLock); + rwlock->wRWLock = NULL; + + return 0; + #else + return pthread_rwlock_destroy(&rwlock->pRWLock); + #endif + } +#endif + +int cthreads_error_code(void) { + #ifdef CTHREADS_DEBUG + puts("cthreads_error_code"); + #endif + + #ifdef _WIN32 + return GetLastError(); + #else + return errno; + #endif +} + +size_t cthreads_error_string(int error_code, char *buf, size_t length) { + #ifdef CTHREADS_DEBUG + puts("cthreads_error_string"); + #endif + + if (length == 0) return 0; + + #ifdef _WIN32 + LPSTR error_str = NULL; + + /* + INFO: The string that is written also contains a \n, which we must ignore, besides the + NULL terminator. + */ + DWORD format_ret = FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + NULL, error_code, MAKELANGID(LANG_NEUTRAL, SUBLANG_DEFAULT), (LPSTR)&error_str, 0, NULL); + if (format_ret == 0) { + strncpy(buf, "Unknown error", length - 1); + buf[length - 1] = '\0'; + + return sizeof("Unknown error"); + } + + size_t error_str_len = (size_t)format_ret; + + /* INFO: Remove trailing newline and carriage return */ + while (error_str_len > 0 && (error_str[error_str_len - 1] == '\n' || error_str[error_str_len - 1] == '\r')) + error_str_len--; + #else + char error_buf[256]; + #if defined(__GLIBC__) && defined(_GNU_SOURCE) + const char *error_str = strerror_r(error_code, error_buf, sizeof(error_buf)); + #else + int strerror_ret = strerror_r(error_code, error_buf, sizeof(error_buf)); + const char *error_str = (strerror_ret == 0) ? error_buf : "Unknown error"; + #endif + const size_t error_str_len = strlen(error_str); + #endif + + size_t final_len = length > error_str_len ? error_str_len : length - 1; + if (final_len == 0) return 0; + + strncpy(buf, (char *)error_str, final_len); + buf[final_len] = '\0'; + + #ifdef _WIN32 + LocalFree(error_str); + #endif + + return error_str_len + 1; +} + +#ifdef CTHREADS_SEMAPHORE + int cthreads_sem_init(struct cthreads_semaphore *sem, int initial_count) { + #ifdef CTHREADS_DEBUG + puts("cthreads_sem_init"); + #endif + + #ifdef _WIN32 + sem->wSemaphore = CreateSemaphore(NULL, initial_count, LONG_MAX, NULL); + + return sem->wSemaphore == NULL; + #else + return sem_init(&sem->pSemaphore,0, initial_count); + #endif + } + + int cthreads_sem_wait(struct cthreads_semaphore *sem) { + #ifdef CTHREADS_DEBUG + puts("cthreads_sem_wait"); + #endif + + #ifdef _WIN32 + return (WaitForSingleObject(sem->wSemaphore, INFINITE) != WAIT_OBJECT_0); + #else + return sem_wait(&sem->pSemaphore); + #endif + } + + int cthreads_sem_trywait(struct cthreads_semaphore *sem) { + #ifdef CTHREADS_DEBUG + puts("cthreads_sem_trywait"); + #endif + + #ifdef _WIN32 + DWORD ret = WaitForSingleObject(sem->wSemaphore, 0); + if (ret == WAIT_OBJECT_0) return 0; + if (ret == WAIT_TIMEOUT) SetLastError(ERROR_TIMEOUT); + + return -1; + #else + return sem_trywait(&sem->pSemaphore); + #endif + } + + int cthreads_sem_timedwait(struct cthreads_semaphore *sem, unsigned int ms) { + #ifdef CTHREADS_DEBUG + puts("cthreads_sem_timedwait"); + #endif + + #ifdef _WIN32 + DWORD ret = WaitForSingleObject(sem->wSemaphore, (DWORD)ms); + if (ret == WAIT_OBJECT_0) return 0; + if (ret == WAIT_TIMEOUT) SetLastError(ERROR_TIMEOUT); + + return -1; + #else + struct timespec ts; + if (clock_gettime(CLOCK_REALTIME, &ts)) return 1; + + ts.tv_sec += ms / 1000; + ts.tv_nsec += (ms % 1000) * 1000000; + ts.tv_sec += ts.tv_nsec / 1000000000; + ts.tv_nsec %= 1000000000; + + return sem_timedwait(&sem->pSemaphore, &ts); + #endif + } + int cthreads_sem_post(struct cthreads_semaphore *sem) { + #ifdef CTHREADS_DEBUG + puts("cthreads_sem_post"); + #endif + + #ifdef _WIN32 + return ReleaseSemaphore(sem->wSemaphore, 1, NULL) == 0; + #else + return sem_post(&sem->pSemaphore); + #endif + } + + int cthreads_sem_destroy(struct cthreads_semaphore *sem) { + #ifdef CTHREADS_DEBUG + puts("cthreads_sem_destroy"); + #endif + + #ifdef _WIN32 + return CloseHandle(sem->wSemaphore) == 0; + #else + return sem_destroy(&sem->pSemaphore); + #endif + } +#endif + diff --git a/native/src/cthreads.h b/native/src/cthreads.h new file mode 100644 index 0000000..55938a8 --- /dev/null +++ b/native/src/cthreads.h @@ -0,0 +1,553 @@ +#ifndef CTHREADS_H +#define CTHREADS_H + +struct cthreads_args { + void *(*func)(void *data); + void *data; +}; + +#ifdef _WIN32 + #include + #define CTHREADS_SEMAPHORE 1 +#else + #include +#endif + +#ifdef _WIN32 + #define CTHREADS_THREAD_DWCREATIONFLAGS 1 + + #define CTHREADS_THREAD_STACK 1 + + #define CTHREADS_RWLOCK 1 +#else + #define CTHREADS_THREAD_DETACHSTATE 1 + #define CTHREADS_THREAD_GUARDSIZE 1 + #ifndef __ANDROID__ + #define CTHREADS_THREAD_INHERITSCHED 1 + #endif + #define CTHREADS_THREAD_SCHEDPOLICY 1 + #define CTHREADS_THREAD_SCOPE 1 + #if _POSIX_C_SOURCE >= 200112L + #include + #define CTHREADS_SEMAPHORE 1 + #define CTHREADS_THREAD_STACK 1 + #endif + + #ifdef _POSIX_THREAD_ATTR_STACKADDR + #define CTHREADS_THREAD_STACKADDR 1 + #endif + + #define CTHREADS_MUTEX_ATTR 1 + + #define CTHREADS_MUTEX_PSHARED 1 + #if _POSIX_C_SOURCE >= 200809L + #define CTHREADS_MUTEX_TYPE 1 + #endif + #if _POSIX_C_SOURCE >= 200112L + #if (defined __linux__ || defined __FreeBSD__) && !defined __ANDROID__ + #define CTHREADS_MUTEX_ROBUST 1 + #endif + #endif + #ifndef __ANDROID__ + #define CTHREADS_MUTEX_PROTOCOL 1 + #define CTHREADS_MUTEX_PRIOCEILING 1 + #endif + + #define CTHREADS_COND_ATTR 1 + + #define CTHREADS_COND_PSHARED 1 + #if _POSIX_C_SOURCE >= 200112L + #define CTHREADS_COND_CLOCK 1 + #endif + + #if _POSIX_C_SOURCE >= 200112L + #define CTHREADS_RWLOCK 1 + #endif +#endif + +struct cthreads_thread { + #ifdef _WIN32 + HANDLE wThread; + DWORD wThreadId; + #else + pthread_t pThread; + #endif +}; + +struct cthreads_thread_attr { + size_t stacksize; + #ifdef _WIN32 + int dwCreationFlags; + #else + void *stackaddr; + int detachstate; + size_t guardsize; + int inheritsched; + int schedpolicy; + int scope; + #ifdef CTHREADS_THREAD_STACK + size_t stack; + #endif + #endif +}; + +struct cthreads_mutex { + #ifdef _WIN32 + CRITICAL_SECTION wMutex; + #else + pthread_mutex_t pMutex; + #endif +}; + +#ifdef CTHREADS_MUTEX_ATTR + struct cthreads_mutex_attr { + #ifndef _WIN32 + int pshared; + #ifdef CTHREADS_MUTEX_TYPE + int type; + #endif + #ifdef CTHREADS_MUTEX_ROBUST + int robust; + #endif + #ifdef CTHREADS_MUTEX_PROTOCOL + int protocol; + #endif + #ifdef CTHREADS_MUTEX_PRIOCEILING + int prioceiling; + #endif + #endif + }; +#endif + +struct cthreads_cond { + #ifdef _WIN32 + CONDITION_VARIABLE wCond; + #else + pthread_cond_t pCond; + #ifdef CTHREADS_COND_CLOCK + int clock; + #endif + #endif +}; + +#ifdef CTHREADS_COND_ATTR + struct cthreads_cond_attr { + #ifndef _WIN32 + int pshared; + #ifdef CTHREADS_COND_CLOCK + int clock; + #endif + #endif + }; +#endif + +#ifdef CTHREADS_RWLOCK +struct cthreads_rwlock { + #ifdef _WIN32 + PSRWLOCK wRWLock; + #else + pthread_rwlock_t pRWLock; + #endif +}; +#endif + +#ifdef CTHREADS_SEMAPHORE + struct cthreads_semaphore { + #ifdef _WIN32 + HANDLE wSemaphore; + #else + sem_t pSemaphore; + #endif + }; +#endif + +/** + * Creates a new thread. + * + * - pthread: pthread_create + * - windows threads: CreateThread + * + * @param thread Pointer to the thread structure to be filled with the new thread information. + * @param attr Pointer to the thread attributes. Set it to NULL for default attributes. + * @param func Pointer to the function that will be executed in the new thread. + * @param data Pointer to the data that will be passed to the thread function. + * @param args Pointer to the thread arguments. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_thread_create(struct cthreads_thread *thread, struct cthreads_thread_attr *attr, void *(*func)(void *data), void *data, struct cthreads_args *args); + +/** + * Detaches a thread. + * + * - pthread: pthread_detach + * - windows threads: CloseHandle + * + * @param thread Thread structure to be detached. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_thread_detach(struct cthreads_thread thread); + +/** + * Joins a thread. + * + * - pthread: pthread_join + * - windows threads: WaitForSingleObject & GetExitCodeThread + * + * @param thread Pointer to the thread structure to be joined. + * @param code Pointer to store the exit code of the joined thread. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_thread_join(struct cthreads_thread thread, void *code); + +/** + * Compares two thread structures for equality. + * + * - pthread: pthread_equal + * - windows threads: GetCurrentThreadId + * + * @param thread1 First thread structure to compare. + * @param thread2 Second thread structure to compare. + * @return 1 if the threads are equal, zero otherwise. + */ +int cthreads_thread_equal(struct cthreads_thread thread1, struct cthreads_thread thread2); + +/** + * Retrieves the thread struct of the current thread. + * + * - pthread: pthread_self + * - windows threads: GetCurrentThreadId + * + * @return Thread struct of the current thread. + */ +struct cthreads_thread cthreads_thread_self(void); + +/** + * Retrieves the thread identifier of the specified thread. + * + * - pthread: N/A + * - windows threads: GetThreadId + * + * @param thread Thread structure to retrieve the identifier from. + * @return Thread identifier of the specified thread. +*/ +unsigned long cthreads_thread_id(struct cthreads_thread thread); + +/** + * Exits a thread. + * + * - pthread: pthread_exit + * - windows threads: ExitThread + * + * @param code Pointer to the thread exit code. + */ +void cthreads_thread_exit(void *code); + +/** + * Cancels a thread. + * + * - pthread: pthread_cancel + * - windows threads: TerminateThread + * + * @param thread Thread structure to be canceled. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_thread_cancel(struct cthreads_thread thread); + +/** + * Initializes a mutex. + * + * - pthread: pthread_mutex_init + * - windows threads: InitializeCriticalSection + * + * @param mutex Pointer to the mutex structure to be initialized. + * @param attr Pointer to the mutex attributes. Set it to NULL for default attributes. Only available if CTHREADS_MUTEX_ATTR is defined. + * @return 0 on success, non-zero error code on failure. + */ +#ifdef CTHREADS_MUTEX_ATTR + int cthreads_mutex_init(struct cthreads_mutex *mutex, struct cthreads_mutex_attr *attr); +#else + int cthreads_mutex_init(struct cthreads_mutex *mutex, void *attr); +#endif + +/** + * Locks a mutex. + * + * - pthread: pthread_mutex_lock + * - windows threads: EnterCriticalSection + * + * @param mutex Pointer to the mutex structure to be locked. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_mutex_lock(struct cthreads_mutex *mutex); + +/** + * Tries to lock a mutex without blocking. + * + * - pthread: pthread_mutex_trylock + * - windows threads: TryEnterCriticalSection + * + * @param mutex Pointer to the mutex structure to be locked. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_mutex_trylock(struct cthreads_mutex *mutex); + +/** + * Unlocks a mutex. + * + * - pthread: pthread_mutex_unlock + * - windows threads: LeaveCriticalSection + * + * @param mutex Pointer to the mutex structure to be unlocked. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_mutex_unlock(struct cthreads_mutex *mutex); + +/** + * Destroys a mutex. + * + * - pthread: pthread_mutex_destroy + * - windows threads: DeleteCriticalSection + * + * @param mutex Pointer to the mutex structure to be destroyed. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_mutex_destroy(struct cthreads_mutex *mutex); + +/** + * Initializes a condition variable. + * + * - pthread: pthread_cond_init + * - windows threads: InitializeConditionVariable + * + * @param cond Pointer to the condition variable structure to be initialized. + * @param attr Pointer to the condition variable attributes. Set it to NULL for default attributes. Only available if CTHREADS_COND_ATTR is defined. + * @return 0 on success, non-zero error code on failure. + */ +#ifdef CTHREADS_COND_ATTR + int cthreads_cond_init(struct cthreads_cond *cond, struct cthreads_cond_attr *attr); +#else + int cthreads_cond_init(struct cthreads_cond *cond, void *attr); +#endif + +/** + * Signals a condition variable. + * + * - pthread: pthread_cond_signal + * - windows threads: WakeConditionVariable + * + * @param cond Pointer to the condition variable structure. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_cond_signal(struct cthreads_cond *cond); + +/** + * Broadcasts a condition variable. + * + * - pthread: pthread_cond_broadcast + * - windows threads: WakeAllConditionVariable + * + * @param cond Pointer to the condition variable structure. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_cond_broadcast(struct cthreads_cond *cond); + +/** + * Destroys a condition variable. + * + * - pthread: pthread_cond_destroy + * - windows threads: DeleteConditionVariable + * + * @param cond Pointer to the condition variable structure to be destroyed. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_cond_destroy(struct cthreads_cond *cond); + +/** + * Waits on a condition variable. + * + * - pthread: pthread_cond_wait + * - windows threads: SleepConditionVariableCS + * + * @param cond Pointer to the condition variable structure. + * @param mutex Pointer to the associated mutex structure. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_cond_wait(struct cthreads_cond *cond, struct cthreads_mutex *mutex); + +/** + * Waits on a condition variable till set ms. + * + * - pthread: pthread_cond_timedwait + * - windows threads: SleepConditionVariableCS + * + * @param cond Pointer to the condition variable structure. + * @param mutex Pointer to the associated mutex structure. + * @param ms Time in milliseconds to unlock if not unlocked in time. + * @return 0 on success, non-zero error code on failure. + */ +int cthreads_cond_timedwait(struct cthreads_cond *cond, struct cthreads_mutex *mutex, unsigned int ms); + +#ifdef CTHREADS_RWLOCK + /** + * Initializes a read-write lock. + * + * - pthread: pthread_rwlock_init + * - windows threads: InitializeSRWLock + * + * @param rwlock Pointer to the read-write lock structure to be initialized. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_rwlock_init(struct cthreads_rwlock *rwlock); + + /** + * Acquires a read lock on a read-write lock. + * + * - pthread: pthread_rwlock_rdlock + * - windows threads: AcquireSRWLockShared + * + * @param rwlock Pointer to the read-write lock structure to be locked. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_rwlock_rdlock(struct cthreads_rwlock *rwlock); + + /** + * Unlocks a read-write shared lock. + * + * - pthread: pthread_rwlock_unlock + * - windows threads: ReleaseSRWLockShared + * + * @note Calling this is UB if the lock was acquired by `cthreads_rwlock_wrlock` on Windows, but not POSIX. + * @param rwlock Pointer to the read-write lock structure to be unlocked. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_rwlock_unlock_shared(struct cthreads_rwlock *rwlock); + + /** + * Unlocks a read-write exclusive lock. + * + * - pthread: pthread_rwlock_unlock + * - windows threads: ReleaseSRWLockExclusive + * + * @note Calling this is UB if the lock was acquired by `cthreads_rwlock_rdlock` on Windows, but not POSIX. + * @param rwlock Pointer to the read-write lock structure to be unlocked. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_rwlock_unlock_exclusive(struct cthreads_rwlock *rwlock); + + /** + * Acquires a write lock on a read-write lock. + * + * - pthread: pthread_rwlock_wrlock + * - windows threads: AcquireSRWLockExclusive + * + * @param rwlock Pointer to the read-write lock structure to be locked. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_rwlock_wrlock(struct cthreads_rwlock *rwlock); + + /** + * Destroys a read-write lock. + * + * - pthread: pthread_rwlock_destroy + * - windows threads: N/A + * + * @param rwlock Pointer to the read-write lock structure to be destroyed. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_rwlock_destroy(struct cthreads_rwlock *rwlock); +#endif + +/** + * Returns the platform-specific error code. + * + * - pthread: errno + * - windows: GetLastError() + * + * @return Platform-specific error code + */ +int cthreads_error_code(void); + +/** + * Obtains the error code and writes at most `length` + * bytes of the associated message to `buf`. + * + * @param error_code Platform-specific error code. (See: `cthreads_error_code()`) + * @param buf Buffer of `length` bytes and target of the error message + * @param length Length of the provided buffer + * + * @return Number of bytes required to print the message + NULL-terminator + */ +size_t cthreads_error_string(int error_code, char *buf, size_t length); + +#ifdef CTHREADS_SEMAPHORE + /** + * Initializes a semaphore. + * + * - pthread: sem_init() + * - windows threads: CreateSemaphore() + * + * @param semaphore Pointer to the semaphore structure to be initialized. + * @param initial count of the semaphore + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_sem_init(struct cthreads_semaphore *sem, int initial_count); + + /** + * Decrease a semaphore. + * + * - pthread: sem_wait() + * - windows threads: WaitForSingleObject() + * + * @param semaphore Pointer to the semaphore structure to be decreased. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_sem_wait(struct cthreads_semaphore *sem); + + /** + * Tries to decrease a semaphore without blocking. + * + * - pthread: sem_trywait() + * - windows threads: WaitForSingleObject() + * + * @param semaphore Pointer to the semaphore structure to be decreased. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_sem_trywait(struct cthreads_semaphore *sem); + + + /** + * Waits on a condition variable till set ms. + * + * - pthread: sem_timedwait + * - windows threads: WaitForSingleObject() + * + * @param semaphore Pointer to the semaphore structure to be decreased. + * @param ms Time in milliseconds to unlock if not unlocked in time. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_sem_timedwait(struct cthreads_semaphore *sem, unsigned int ms); + + /** + * Increase a semaphore. + * + * - pthread: sem_post() + * - windows threads: ReleaseSemaphore() + * + * @param semaphore Pointer to the semaphore structure to be increased. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_sem_post(struct cthreads_semaphore *sem); + + /** + * Destroy a semaphore. + * + * - pthread: sem_destroy() + * - windows threads: CloseHandle() + * + * @param semaphore Pointer to the semaphore structure to be destroyed. + * @return 0 on success, non-zero error code on failure. + */ + int cthreads_sem_destroy(struct cthreads_semaphore *sem); +#endif + +#endif /* CTHREADS_H */ diff --git a/native/src/os_compat.h b/native/src/os_compat.h new file mode 100644 index 0000000..51bf33e --- /dev/null +++ b/native/src/os_compat.h @@ -0,0 +1,65 @@ +#ifndef OS_COMPAT_H +#define OS_COMPAT_H + +#ifdef _WIN32 + #define WIN32_LEAN_AND_MEAN + + #include + #include + #include + #include + +#else + #include + + #include +#endif + +#ifdef _WIN32 + typedef SOCKET os_socket_t; + #define OS_INVALID_SOCKET INVALID_SOCKET +#else + typedef int os_socket_t; + #define OS_INVALID_SOCKET (-1) +#endif + +static inline int os_sendto(os_socket_t fd, const void *buf, size_t len, int flags, const void *addr, size_t addr_len) { + #ifdef _WIN32 + int sent = sendto(fd, (const char *)buf, (int)len, flags, (const struct sockaddr *)addr, (int)addr_len); + return sent == SOCKET_ERROR ? -1 : sent; + #else + return (int)sendto(fd, buf, len, flags, (const struct sockaddr *)addr, (socklen_t)addr_len); + #endif +} + +static inline int os_inet_pton(const char *src, void *dst) { + #ifdef _WIN32 + return InetPtonA(AF_INET, src, dst); + #else + return inet_pton(AF_INET, src, dst); + #endif +} + +static inline long long os_now_ns(void) { + #ifdef _WIN32 + static LARGE_INTEGER freq; + static int freq_initialized = 0; + + if (!freq_initialized) { + QueryPerformanceFrequency(&freq); + freq_initialized = 1; + } + + LARGE_INTEGER now; + QueryPerformanceCounter(&now); + + return (now.QuadPart * 1000000000LL) / freq.QuadPart; + #else + struct timespec ts; + clock_gettime(CLOCK_MONOTONIC, &ts); + + return (long long)ts.tv_sec * 1000000000LL + ts.tv_nsec; + #endif +} + +#endif /* OS_COMPAT_H */ diff --git a/native/src/utils.h b/native/src/utils.h new file mode 100644 index 0000000..266b0f4 --- /dev/null +++ b/native/src/utils.h @@ -0,0 +1,38 @@ +#ifndef VOICE_UTILS_H +#define VOICE_UTILS_H + +#define NAPI_GET_VALUE_EXTRA(name, idx, type, extra_check, str) \ + status = napi_get_value_ ## type(env, args[idx], &name); \ + if (status != napi_ok || !(extra_check)) { \ + napi_throw_type_error(env, NULL, #name " must be a " #type " and " #str); \ + \ + return NULL; \ + } + +#define NAPI_GET_VALUE(name, idx, type) NAPI_GET_VALUE_EXTRA(name, idx, type, 1, "") + +#define NAPI_UNWRAP_CTX(name) \ + status = unwrap_ctx(env, args[0], &name); \ + if (status != napi_ok || name == NULL) { \ + napi_throw_type_error(env, NULL, "Invalid context"); \ + \ + return NULL; \ + } + +#define NAPI_GET_CB_INFO(args_len) \ + size_t argc = args_len; \ + napi_value args[args_len]; \ + napi_status status = napi_get_cb_info(env, info, &argc, args, NULL, NULL); \ + if (status != napi_ok || argc < args_len) { \ + napi_throw_type_error(env, NULL, "Expected " #args_len " arguments"); \ + \ + return NULL; \ + } + +#define NAPI_INIT_FN_INFO(args_len) \ + NAPI_GET_CB_INFO(args_len) \ + \ + struct native_ctx *nc; \ + NAPI_UNWRAP_CTX(nc) + +#endif /* VOICE_UTILS_H */ diff --git a/native/src/voice_send.c b/native/src/voice_send.c new file mode 100644 index 0000000..066a9c4 --- /dev/null +++ b/native/src/voice_send.c @@ -0,0 +1,153 @@ +#include "voice_send.h" + +#include + +#include + +#if defined(__GNUC__) || defined(__clang__) + __attribute__((constructor)) +#elif defined(_MSC_VER) + #pragma section(".CRT$XCU", long, read) +#endif +static void auto_sodium_init(void) { + if (sodium_init() < 0) { + fprintf(stderr, "ERROR: libsodium initialization failed\n"); + abort(); + } +} + +#ifdef _MSC_VER + __declspec(allocate(".CRT$XCU")) static void (*auto_sodium_init_ptr)(void) = auto_sodium_init; +#endif + +const uint8_t OPUS_SILENCE_FRAME[] = { + 0xF8, 0xFF, 0xFE +}; + +/* INFO: Write a 16-bit value in network (big-endian) order. */ +static void wbe16(uint8_t *p, uint16_t v) { + p[0] = (uint8_t)(v >> 8); + p[1] = (uint8_t)(v); +} + +/* INFO: Write a 32-bit value in network (big-endian) order. */ +static void wbe32(uint8_t *p, uint32_t v) { + p[0] = (uint8_t)(v >> 24); + p[1] = (uint8_t)(v >> 16); + p[2] = (uint8_t)(v >> 8); + p[3] = (uint8_t)(v); +} + +struct voice_send_ctx *voice_send_init(struct voice_send_ctx *ctx, + enum voice_encryption_mode encryption, + const uint8_t secret_key[32], + uint32_t ssrc, + uint16_t initial_sequence, + uint32_t initial_timestamp) { + ctx->encryption = encryption; + ctx->ssrc = ssrc; + ctx->sequence = initial_sequence; + ctx->timestamp = initial_timestamp; + ctx->nonce = 0; + + memset(&ctx->stats, 0, sizeof(ctx->stats)); + memcpy(ctx->secret_key, secret_key, 32); + + return ctx; +} + +void voice_send_destroy(struct voice_send_ctx *ctx) { + sodium_memzero(ctx->secret_key, sizeof(ctx->secret_key)); +} + +/* INFO: Core encryption + packet building */ +int voice_send_encrypt(struct voice_send_ctx *ctx, + const uint8_t *audio_chunk, size_t audio_len, + uint8_t *packet, size_t packet_capacity) { + if (audio_chunk == NULL || audio_len == 0 || packet == NULL) return -1; + if (packet_capacity < VOICE_MAX_PACKET_SIZE) return -1; + + /* INFO: RTP header (12 bytes) */ + packet[0] = 0x80; /* version 2 */ + packet[1] = 0x78; /* payload type 120 */ + wbe16(packet + 2, ctx->sequence); + wbe32(packet + 4, ctx->timestamp); + wbe32(packet + 8, ctx->ssrc); + + /* INFO: Nonce (24 bytes) */ + uint8_t nonce_buf[24] = { 0 }; + wbe32(nonce_buf, ctx->nonce); + + unsigned long long cipher_len = 0; + /* INFO: Encrypt */ + switch (ctx->encryption) { + case VOICE_ENCRYPTION_XCHACHA20: { + if (crypto_aead_xchacha20poly1305_ietf_encrypt(packet + RTP_HEADER_SIZE, &cipher_len, + audio_chunk, audio_len, + packet, RTP_HEADER_SIZE, + NULL, + nonce_buf, + ctx->secret_key) < 0) { + return -1; + } + + break; + } + + case VOICE_ENCRYPTION_AES256_GCM: { + #ifdef crypto_aead_aes256gcm_NPUBBYTES + if (!crypto_aead_aes256gcm_is_available()) + return -1; + + if (crypto_aead_aes256gcm_encrypt(packet + RTP_HEADER_SIZE, &cipher_len, + audio_chunk, audio_len, + packet, RTP_HEADER_SIZE, + NULL, + nonce_buf, + ctx->secret_key) < 0) { + return -1; + } + #else + return -1; + #endif + + break; + } + + default: return -1; + } + + /* INFO: Append 4-byte nonce at end */ + size_t off = RTP_HEADER_SIZE + (size_t)cipher_len; + wbe32(packet + off, ctx->nonce); + int ret = (int)(off + 4); + + /* INFO: Advance counters (after encryption, to preserve nonce = 0 on first) */ + ctx->sequence = (uint16_t)(ctx->sequence + 1); + ctx->timestamp = (ctx->timestamp + TIMESTAMP_INCREMENT) & 0xFFFFFFFFu; + ctx->nonce = (ctx->nonce + 1) & 0xFFFFFFFFu; + + ctx->stats.packets_expected++; + + return ret; +} + +uint16_t voice_send_sequence(const struct voice_send_ctx *ctx) { + return ctx->sequence; +} + +uint32_t voice_send_timestamp(const struct voice_send_ctx *ctx) { + return ctx->timestamp; +} + +uint32_t voice_send_nonce(const struct voice_send_ctx *ctx) { + return ctx->nonce; +} + +struct voice_send_stats voice_send_stats(const struct voice_send_ctx *ctx) { + return ctx->stats; +} + +void voice_send_reset_nonce(struct voice_send_ctx *ctx, uint32_t nonce) { + ctx->nonce = nonce; +} diff --git a/native/src/voice_send.h b/native/src/voice_send.h new file mode 100644 index 0000000..bfb891a --- /dev/null +++ b/native/src/voice_send.h @@ -0,0 +1,58 @@ +#ifndef VOICE_SEND_H +#define VOICE_SEND_H + +#include +#include + +extern const uint8_t OPUS_SILENCE_FRAME[]; +#define OPUS_SILENCE_FRAME_SIZE 3 + +#define VOICE_MAX_PACKET_SIZE 1232 +#define RTP_HEADER_SIZE 12 +#define TIMESTAMP_INCREMENT 960 + +enum voice_encryption_mode { + VOICE_ENCRYPTION_AES256_GCM = 0, + VOICE_ENCRYPTION_XCHACHA20 = 1 +}; + +struct voice_send_stats { + uint64_t packets_sent; + uint64_t packets_lost; + uint64_t packets_expected; +}; + +struct voice_send_ctx { + enum voice_encryption_mode encryption; + uint8_t secret_key[32]; + uint32_t ssrc; + uint16_t sequence; + uint32_t timestamp; + uint32_t nonce; + struct voice_send_stats stats; +}; + +struct voice_send_ctx *voice_send_init(struct voice_send_ctx *ctx, + enum voice_encryption_mode encryption, + const uint8_t secret_key[32], + uint32_t ssrc, + uint16_t initial_sequence, + uint32_t initial_timestamp); + +void voice_send_destroy(struct voice_send_ctx *ctx); + +int voice_send_encrypt(struct voice_send_ctx *ctx, + const uint8_t *audio_chunk, size_t audio_len, + uint8_t *packet, size_t packet_capacity); + +uint16_t voice_send_sequence(const struct voice_send_ctx *ctx); + +uint32_t voice_send_timestamp(const struct voice_send_ctx *ctx); + +uint32_t voice_send_nonce(const struct voice_send_ctx *ctx); + +struct voice_send_stats voice_send_stats(const struct voice_send_ctx *ctx); + +void voice_send_reset_nonce(struct voice_send_ctx *ctx, uint32_t nonce); + +#endif /* VOICE_SEND_H */ diff --git a/package.json b/package.json index 994cc75..9cd11f5 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,18 @@ "url": "https://github.com/PerformanC/voice/issues" }, "homepage": "https://github.com/PerformanC/voice#readme", + "scripts": { + "install": "node-gyp rebuild --directory native", + "build:native": "node-gyp rebuild --directory native", + "test": "make -C native test" + }, "dependencies": { "@performanc/pwsl": "github:PerformanC/Internals#PWSL", - "@snazzah/davey": "^0.1.12" + "@snazzah/davey": "^0.1.12", + "node-gyp": "^10.0.0" }, "optionalDependencies": { "libsodium-wrappers": "^0.8.2", - "sodium-native": "^5.1.0", - "@toddynnn/udpqueue": "^1.0.7" + "sodium-native": "^5.1.0" } }