UNPKG

@coze/uniapp-api

Version:

Official Coze UniApp SDK for seamless AI integration into your applications | 扣子官方 UniApp SDK,助您轻松集成 AI 能力到应用中

680 lines (679 loc) 25.9 kB
"use strict"; var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; Object.defineProperty(exports, "__esModule", { value: true }); exports.PcmStreamPlayer = void 0; /* eslint-disable @typescript-eslint/no-explicit-any */ const resampler_1 = require("./resampler"); const g711_1 = require("./codecs/g711"); /** * PcmStreamPlayer for WeChat Mini Program * Plays audio streams received in raw PCM16, G.711a, or G.711u chunks * @class */ class PcmStreamPlayer { /** * Creates a new PcmStreamPlayer instance * @param {{sampleRate?: number, defaultFormat?: AudioFormat}} options * @returns {PcmStreamPlayer} */ constructor({ sampleRate = 24000, defaultFormat = 'pcm', volume = 1.0, } = {}) { Object.defineProperty(this, "audioContext", { enumerable: true, configurable: true, writable: true, value: null }); // Using 'any' for WebAudioContext due to type limitations Object.defineProperty(this, "inputSampleRate", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "outputSampleRate", { enumerable: true, configurable: true, writable: true, value: void 0 }); Object.defineProperty(this, "audioQueue", { enumerable: true, configurable: true, writable: true, value: [] }); Object.defineProperty(this, "volume", { enumerable: true, configurable: true, writable: true, value: 1.0 }); // Volume level from 0.0 (muted) to 1.0 (full volume) Object.defineProperty(this, "trackSampleOffsets", { enumerable: true, configurable: true, writable: true, value: {} }); Object.defineProperty(this, "interruptedTrackIds", { enumerable: true, configurable: true, writable: true, value: {} }); Object.defineProperty(this, "isInitialized", { enumerable: true, configurable: true, writable: true, value: false }); Object.defineProperty(this, "isProcessing", { enumerable: true, configurable: true, writable: true, value: false }); Object.defineProperty(this, "scriptNode", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "bufferSize", { enumerable: true, configurable: true, writable: true, value: 1024 }); Object.defineProperty(this, "base64Queue", { enumerable: true, configurable: true, writable: true, value: [] }); Object.defineProperty(this, "isProcessingQueue", { enumerable: true, configurable: true, writable: true, value: false }); Object.defineProperty(this, "lastAudioProcessTime", { enumerable: true, configurable: true, writable: true, value: Infinity }); Object.defineProperty(this, "processingTimeThreshold", { enumerable: true, configurable: true, writable: true, value: 0 }); // 1ms threshold Object.defineProperty(this, "isPaused", { enumerable: true, configurable: true, writable: true, value: false }); // Track pause state // Current buffer and position for continuous playback Object.defineProperty(this, "currentBuffer", { enumerable: true, configurable: true, writable: true, value: null }); Object.defineProperty(this, "playbackPosition", { enumerable: true, configurable: true, writable: true, value: 0 }); /** * Default audio format */ Object.defineProperty(this, "defaultFormat", { enumerable: true, configurable: true, writable: true, value: 'pcm' }); this.inputSampleRate = sampleRate; // 微信小程序,输出的采样率是固定的,所有需要重采样 this.outputSampleRate = PcmStreamPlayer.getSampleRate(); this.defaultFormat = defaultFormat; this.setVolume(volume); } /** * Initialize the audio context * @private * @returns {boolean} */ initialize() { if (this.isInitialized) { return true; } try { // Create WebAudioContext for UniApp environment this.audioContext = uni.createWebAudioContext(); if (!this.audioContext) { console.error('Failed to create WebAudioContext'); return false; } // 在下一帧音频处理前5ms,确保主线程是空闲的 this.processingTimeThreshold = Math.floor((this.bufferSize / this.audioContext.sampleRate) * 1000) - 5; // Initialize silent audio trigger for iPhone this.initSilentModeTrigger(); this.isInitialized = true; return true; } catch (error) { console.error('Error initializing audio context:', error); return false; } } /** * Initialize a silent audio player to bypass iPhone silent mode * @private */ initSilentModeTrigger() { try { // Only initialize once if (!PcmStreamPlayer.triggerAudio) { uni.setInnerAudioOption({ obeyMuteSwitch: false, // 设置为false以忽略静音开关 success: () => { console.log('Inner audio option set obeyMuteSwitch=false successfully'); }, fail: err => { console.error('Failed to set inner audio option:', err); }, }); // Create and configure the trigger audio const triggerAudio = uni.createInnerAudioContext(); // Use a base64 silent audio as a temporary placeholder until loaded // In real implementation, should use a file in the app package triggerAudio.src = 'data:audio/mp3;base64,SUQzBAAAAAAAI1RTU0UAAAAPAAADTGF2ZjU4Ljc2LjEwMAAAAAAAAAAAAAAA//tQwAAAAAAAAAAAAAAAAAAAAAAASW5mbwAAAA8AAAACAAABIADAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV6urq6urq6urq6urq6urq6urq6urq6urq6v////////////////////////////////8AAAAATGF2YzU4LjEzAAAAAAAAAAAAAAAAJAAAAAAAAAAAASAthz7PAAAAAAAAAAAAAAAA//tAwAAABpADjMQAACK2IHbYwggI0JMZ4M8y5wPEI7iSHf5DMjMH5QdHI25QZIguRmDIJnoZgyDGfCUGQdBjGDa+jm7aGaABBAEAghzNIJhJRmCEYbJkHmUCuMY1/AAIAAQACAQ8QDSSQTJ7gICAwQkDSiYVBpgoBnJDQA=='; triggerAudio.loop = true; triggerAudio.volume = 0.01; // Set to very low volume triggerAudio.obeyMuteSwitch = false; PcmStreamPlayer.triggerAudio = triggerAudio; // Play immediately to bypass iPhone silent mode triggerAudio.onPlay = () => { console.log('Trigger audio played'); }; triggerAudio.play(); } } catch (error) { console.error('Error initializing silent mode trigger:', error); } } /** * Start audio playback system * @private */ startPlayback() { var _a, _b; try { // Initialize audio if needed if (!this.isInitialized) { const initialized = this.initialize(); if (!initialized) { return false; } } // If there is no data in the queue, exit if (this.audioQueue.length === 0) { return false; } // Clean up any existing scriptNode to prevent duplicate audio playback if (this.scriptNode) { try { this.scriptNode.disconnect(); this.scriptNode = null; } catch (error) { console.warn('Error disconnecting previous script node:', error); } } // Using optional chaining to handle potential null and checking if method exists const scriptNode = ((_a = this.audioContext) === null || _a === void 0 ? void 0 : _a.createScriptProcessor) ? this.audioContext.createScriptProcessor(this.bufferSize, 0, 1) : null; if (!scriptNode) { console.error('Failed to create script processor node'); return false; } this.scriptNode = scriptNode; this.isProcessing = true; // Process audio data scriptNode.onaudioprocess = (e) => { const outputBuffer = e.outputBuffer.getChannelData(0); // Fill the output buffer this.fillOutputBuffer(outputBuffer); // Record the last audio process time this.lastAudioProcessTime = Date.now(); // Check if we have base64 data to process and not currently processing this.processBase64Queue(); }; // Connect to destination (speakers) scriptNode.connect((_b = this.audioContext) === null || _b === void 0 ? void 0 : _b.destination); return true; } catch (error) { console.error('Error starting audio playback:', error); return false; } } /** * Fill the output buffer with audio data from the current buffer or queue * @private */ fillOutputBuffer(outputBuffer) { if (!this.currentBuffer) { if (!this.getNextBuffer()) { // No data available, fill with silence for (let i = 0; i < outputBuffer.length; i++) { outputBuffer[i] = 0; } this.isProcessing = false; return; } } // We have a buffer, process it with volume control for (let i = 0; i < outputBuffer.length; i++) { if (this.currentBuffer && this.playbackPosition < this.currentBuffer.length) { // Apply volume scaling to the PCM samples (convert Int16 to Float32 and scale by volume) outputBuffer[i] = this.volume <= 0 ? 0 : (this.currentBuffer[this.playbackPosition] / 0x8000) * this.volume; this.playbackPosition++; } else { // Current buffer is exhausted, try to get next buffer if (!this.getNextBuffer()) { // No more buffers available outputBuffer[i] = 0; this.isProcessing = i !== outputBuffer.length - 1; } else { // Got a new buffer, process it if (this.currentBuffer) { // Apply volume scaling outputBuffer[i] = this.volume <= 0 ? 0 : (this.currentBuffer[this.playbackPosition] / 0x8000) * this.volume; this.playbackPosition++; } else { // Should not happen but still handle it outputBuffer[i] = 0; } this.isProcessing = true; } } } } /** * Get the next buffer from the queue and prepare it for playback * @private * @returns {boolean} True if a new buffer was prepared, false if queue is empty */ getNextBuffer() { // If queue is empty, return false if (this.audioQueue.length === 0) { return false; } // Get the next PCM data from queue const pcmData = this.audioQueue.shift(); if (!pcmData || pcmData.length === 0) { this.currentBuffer = null; return false; } // Keep the data in Int16Array format and only convert when needed during playback this.currentBuffer = pcmData; this.playbackPosition = 0; return true; } /** * Pauses audio playback */ pause() { return __awaiter(this, void 0, void 0, function* () { var _a, _b; if (this.audioContext && !this.isPaused) { try { // In UniApp environment, suspend the audio context // Check if state and suspend methods exist if (((_a = this.audioContext) === null || _a === void 0 ? void 0 : _a.state) === 'running' && typeof ((_b = this.audioContext) === null || _b === void 0 ? void 0 : _b.suspend) === 'function') { yield this.audioContext.suspend(); } this.isPaused = true; } catch (error) { console.error('Error pausing audio playback:', error); } } }); } /** * Resumes audio playback */ resume() { return __awaiter(this, void 0, void 0, function* () { var _a, _b; if (this.audioContext && this.isPaused) { try { // In UniApp environment, resume the audio context // Check if state and resume methods exist if (((_a = this.audioContext) === null || _a === void 0 ? void 0 : _a.state) === 'suspended' && typeof ((_b = this.audioContext) === null || _b === void 0 ? void 0 : _b.resume) === 'function') { yield this.audioContext.resume(); } this.isPaused = false; // If no scriptNode exists, start playback again if (!this.scriptNode && this.audioQueue.length > 0) { yield this.startPlayback(); } } catch (error) { console.error('Error resuming audio playback:', error); } } }); } /** * Toggles between play and pause states */ togglePlay() { return __awaiter(this, void 0, void 0, function* () { if (this.isPaused) { yield this.resume(); } else { yield this.pause(); } }); } /** * Checks if audio is currently playing * @returns {boolean} */ isPlaying() { var _a; return Boolean(this.audioContext && !this.isPaused && (this.isProcessing || this.audioQueue.length > 0) && ((_a = this.audioContext) === null || _a === void 0 ? void 0 : _a.state) === 'running'); } isProcessingIdle() { if (this.lastAudioProcessTime === 0) { return true; } const now = Date.now(); const diff = now - this.lastAudioProcessTime; if (diff > 100 || diff < this.processingTimeThreshold) { return true; } return false; } /** * Adds base64 encoded PCM data to a queue for processing * This prevents blocking the main thread during audio processing * @param {string} base64String - Base64 encoded PCM data * @param {string} trackId - Track identifier * @returns {boolean} - Success status */ addBase64PCM(base64String, trackId = 'default') { // Add to processing queue this.base64Queue.push({ base64String, trackId }); // If we're outside the processing window, try to process the queue if (this.isProcessingIdle()) { this.processBase64Queue(); } return true; } /** * Process the base64 queue when the main thread is idle * @private */ processBase64Queue() { // If already processing or queue is empty, do nothing if (this.isProcessingQueue || this.base64Queue.length === 0) { return; } this.isProcessingQueue = true; try { // Process one item from the queue const item = this.base64Queue.shift(); if (item) { const { base64String, trackId } = item; // console.log( // `Processing base64 data for track ${trackId}, queue size: ${this.base64Queue.length}`, // ); const binaryString = uni.base64ToArrayBuffer(base64String); this.add16BitPCM(binaryString, trackId); } } catch (error) { console.error('Error processing base64 queue:', error); } finally { this.isProcessingQueue = false; // If there are more items and we're still outside the processing window, process the next item if (this.base64Queue.length > 0) { if (this.isProcessingIdle()) { // Use setTimeout to give the main thread a chance to breathe setTimeout(() => this.processBase64Queue(), 0); } } } } /** * Adds audio data to the currently playing audio stream * @param {ArrayBuffer|Int16Array|Uint8Array} arrayBuffer * @param {string} [trackId] * @param {AudioFormat} [format] - Audio format: 'pcm', 'g711a', or 'g711u' * @returns {Int16Array} */ add16BitPCM(arrayBuffer, trackId = 'default', format) { if (typeof trackId !== 'string') { throw new Error('trackId must be a string'); } else if (this.interruptedTrackIds[trackId]) { return new Int16Array(); } let buffer; const audioFormat = format || this.defaultFormat; if (arrayBuffer instanceof Int16Array) { // Already in PCM format buffer = arrayBuffer; } else if (arrayBuffer instanceof Uint8Array) { // Handle different formats based on the specified format if (audioFormat === 'g711a') { buffer = (0, g711_1.decodeAlaw)(arrayBuffer); } else if (audioFormat === 'g711u') { buffer = (0, g711_1.decodeUlaw)(arrayBuffer); } else { // Treat as PCM data in Uint8Array buffer = new Int16Array(arrayBuffer.buffer); } } else if (arrayBuffer instanceof ArrayBuffer) { // Handle different formats based on the specified format if (audioFormat === 'g711a') { buffer = (0, g711_1.decodeAlaw)(new Uint8Array(arrayBuffer)); } else if (audioFormat === 'g711u') { buffer = (0, g711_1.decodeUlaw)(new Uint8Array(arrayBuffer)); } else { // Default to PCM buffer = new Int16Array(arrayBuffer); } } else { throw new Error('argument must be Int16Array, Uint8Array, or ArrayBuffer'); } // Resample the buffer if input and output sample rates are different if (this.inputSampleRate !== this.outputSampleRate) { buffer = resampler_1.Resampler.resample(buffer, this.inputSampleRate, this.outputSampleRate); } // Add to the audio queue this.audioQueue.push(buffer); if (!this.isProcessing && !this.isPaused) { this.startPlayback(); } return buffer; } /** * Gets the offset (sample count) of the currently playing stream * @param {boolean} [interrupt] * @returns {{trackId: string|null, offset: number, currentTime: number} | null} */ getTrackSampleOffset(interrupt = false) { var _a; if (!this.audioContext) { return null; } // Calculate approximate offset based on audio context time const currentTime = ((_a = this.audioContext) === null || _a === void 0 ? void 0 : _a.currentTime) || 0; const offset = Math.floor(currentTime * this.inputSampleRate); const requestId = Date.now().toString(); const trackId = 'default'; // We're using a default track for all audio const result = { trackId, offset, currentTime, }; this.trackSampleOffsets[requestId] = result; if (interrupt && trackId) { this.interruptedTrackIds[trackId] = true; // Clear the audio queue for interrupted track this.audioQueue = []; // Disconnect the current scriptNode if exists if (this.scriptNode) { try { this.scriptNode.disconnect(); this.scriptNode = null; this.currentBuffer = null; this.playbackPosition = 0; this.isPaused = false; } catch (error) { console.warn('Error disconnecting script node:', error); } } this.isProcessing = false; } return result; } /** * Strips the current stream and returns the sample offset of the audio * @returns {{trackId: string|null, offset: number, currentTime: number} | null} */ interrupt() { // Clear current buffer and reset playback position this.currentBuffer = null; this.playbackPosition = 0; return this.getTrackSampleOffset(true); } /** * Set the input sample rate for audio playback * @param {number} sampleRate - The sample rate of the incoming audio data */ setSampleRate(sampleRate) { // We can change the input sample rate at any time this.inputSampleRate = sampleRate; console.log(`Input sample rate set to ${sampleRate}Hz, output sample rate is ${this.outputSampleRate}Hz`); } /** * Set the default audio format * @param {AudioFormat} format */ setDefaultFormat(format) { this.defaultFormat = format; } /** * Adds G.711 A-law encoded audio data to the currently playing audio stream * @param {ArrayBuffer|Uint8Array} arrayBuffer - G.711 A-law encoded data * @param {string} [trackId] * @returns {Int16Array} */ addG711a(arrayBuffer, trackId = 'default') { return this.add16BitPCM(arrayBuffer, trackId, 'g711a'); } /** * Adds G.711 μ-law encoded audio data to the currently playing audio stream * @param {ArrayBuffer|Uint8Array} arrayBuffer - G.711 μ-law encoded data * @param {string} [trackId] * @returns {Int16Array} */ addG711u(arrayBuffer, trackId = 'default') { return this.add16BitPCM(arrayBuffer, trackId, 'g711u'); } /** * Get the WebAudioContext's sample rate * @returns {number} System sample rate * @static */ static getSampleRate() { const audioContext = uni.createWebAudioContext(); const { sampleRate } = audioContext; audioContext.close(); return sampleRate; } /** * Set volume level for audio playback * @param {number} volume - Volume level from 0.0 (muted) to 1.0 (full volume) */ setVolume(volume) { // Ensure volume is between 0 and 1 this.volume = Math.max(0, Math.min(1, volume)); console.log(`Audio volume set to ${this.volume}`); } /** * Get current volume level * @returns {number} Current volume level from 0.0 (muted) to 1.0 (full volume) */ getVolume() { return this.volume; } /** * Cleanup static resources when the app is closed or the page is unloaded */ static cleanup() { if (PcmStreamPlayer.triggerAudio) { PcmStreamPlayer.triggerAudio.stop(); PcmStreamPlayer.triggerAudio.destroy(); PcmStreamPlayer.triggerAudio = null; } } } exports.PcmStreamPlayer = PcmStreamPlayer; // Trigger audio for iPhone silent mode Object.defineProperty(PcmStreamPlayer, "triggerAudio", { enumerable: true, configurable: true, writable: true, value: null }); // Export for use with import {PcmStreamPlayer} from '@coze/uniapp-api/ws-tools' exports.default = PcmStreamPlayer;