实现录音语音功能(Web Android IOS)

实现录音语音功能(Web Android IOS)

cocosCreator js代码
  • 需要用到lame这个库把录音转成mp3的格式方便跨平台
  • 下载库
  • web端把lame.min.js放到cocosCreator项目中,转为插件
  • ios端把lame.h libmp3lame.a 添加到xcode项目中
  • android端把libs添加到android项目中

显示界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313

cc.Class({
    extends: require('baseView'),
    statics: {
        show: function () {
            let bundle = cc.assetManager.getBundle(BUNDLEENUM.FISHGAME);
            if (!bundle) {
                cc.error("not find the bundle " + BUNDLEENUM.FISHGAME);
                return;
            }
            let bname = "pop/chat/chatView";
            bundle.load(bname, cc.Prefab, function (err, prefab) {
                if (err) return;
                let chatView = cc.director.getScene().getChildByName("chatView");
                if (chatView && cc.isValid(chatView)) return;
                if (prefab && prefab instanceof cc.Prefab) {
                    let alert = cc.instantiate(prefab);
                    cc.director.getScene().addChild(alert);
                    alert.name = 'chatView';
                    alert.setPosition(cc.view.getVisibleSize().width / 2, cc.view.getVisibleSize().height / 2);
                    alert.zIndex = cc.vv.global.ALERT_LAYER;
                }
            });
        },
    },
    properties: {
        content: cc.Node,
        chatItem : cc.Node,
        editBox : cc.EditBox,
        recordButton : cc.Button,
        chat_shuo : cc.Node,
    },
    onLoad: function () {
        if (cc.vv.resMgr.getCurSceneName() != SCENES_TYPE.FISHGAME) {
            this.node.destroy();
            return;
        }
        this.chatItem.removeFromParent();
        this.recordButton.node.on(cc.Node.EventType.TOUCH_START, this.startRecording, this);
        this.recordButton.node.on(cc.Node.EventType.TOUCH_END, this.stopRecording, this);
        this.recordButton.node.on(cc.Node.EventType.TOUCH_CANCEL, this.stopRecording, this);
       
        this.start_voice = false;
        this.chat_shuo.active = false;
        this.voice_time = 15;
        this.bgm_v = cc.vv.audioMgr.bgmVolume;
        this.sfx_v = cc.vv.audioMgr.sfxVolume;
        this.mediaRecorder;
        this.audioChunks = [];
        this.start_time = 0;
        this.stop_time = 0;
    },
    start(){
        for (let index = 0; index < cc.vv.global.FISH_TXTS.length; index++) {
            const text = cc.vv.global.FISH_TXTS[index];
            let item = cc.instantiate(this.chatItem);
            item.parent = this.content;
            item.info = text;
            let richtext = item.getComponentInChildren(cc.RichText);
            richtext.string = text;
        }
    },
    async getMicrophoneStream() {
        try {
            const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
            return stream;
        } catch (error) {
            console.error("获取麦克风失败:", error);
            return null;
        }
    },
   //开始录音
    startRecording() {
        if (cc.sys.isBrowser) {
            if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
                // console.error("当前浏览器不支持 getUserMedia()");
                require("updateTips").show("亲爱的客户\n您当前浏览器不支持使用语音功能\n请下载安装包,安装后再使用");
                return;
            }
   
            console.log("请求麦克风权限...");
            this.audioChunks = [];
            if (this.mediaRecorder) {
                this.mediaRecorder.stop();
                this.mediaRecorder = null;
            }
            navigator.mediaDevices.getUserMedia({ audio: true })
                .then((stream) => {
                    if (!(stream instanceof MediaStream)) {
                        console.error("获取的不是 MediaStream:", stream);
                        return;
                    }
   
                    console.log("麦克风权限获取成功:", stream);
                    // **确保 mediaRecorder 重新创建**
                    this.mediaRecorder = new MediaRecorder(stream);
                    this.mediaRecorder.ondataavailable = (event) => {
                        if (event.data.size > 0) {
                            this.audioChunks.push(event.data);
                        }
                    };
                    this.mediaRecorder.onstop = async () => {
                        console.log("录音结束, 开始处理音频...");
                        // 录音结束时调用
                        this.processAudioData(this.audioChunks);
                     
                    };
                    this.mediaRecorder.start();
                    console.log("录音开始...");
                })
                .catch((error) => {
                    console.error("无法获取麦克风权限:", error);
                });
            // return;
        }
        if(cc.vv.utils.isChatApkUpdate()) {
            require("updateTips").show("亲爱的客户\n您现在版本太低无法使用语音功能\n请下载最新版本,安装后再使用");
            return;
        }
     
        // 调用 Android 原生方法开始录音
        if (this.start_voice) {
            return;
        }
        this.bgm_v = cc.vv.audioMgr.bgmVolume;
        this.sfx_v = cc.vv.audioMgr.sfxVolume;
        cc.vv.audioMgr.setBGMVolume(0);
        cc.vv.audioMgr.setSFXVolume(0);
        this.voice_time = 15;
        this.start_voice = true;
        this.chat_shuo.active = true;
        this.chat_shuo.stopAllActions();
        this.chat_shuo.getComponent(sp.Skeleton).clearTracks();
        this.chat_shuo.getComponent(sp.Skeleton).setAnimation(0,"animation",true);
        this.chat_shuo.runAction(cc.repeatForever(cc.sequence([cc.callFunc(()=>{
            if (this.voice_time < 0) {
                this.chat_shuo.stopAllActions();
                this.stopRecording();
                return;
            }
            this.chat_shuo.getChildByName("lab").getComponent(cc.Label).string = this.voice_time;
            this.voice_time--;
        }) , cc.delayTime(1)])));
        cc.vv.device.startRecording();
        this.start_time = new Date().getTime();
    },
    stopRecording() {
        if (cc.sys.isBrowser) {
            if (this.mediaRecorder) {
                this.mediaRecorder.stop();
                // cc.error("录音结束...");
            }
            // return;
        }
     
        // 调用 Android 原生方法停止录音并返回音频数据
        if (this.start_voice == false) {
            return;
        }
        cc.vv.audioMgr.setBGMVolume(this.bgm_v);
        cc.vv.audioMgr.setSFXVolume(this.sfx_v);
        cc.error("stopRecording");
        this.start_voice = false;
        this.chat_shuo.active = false;
        cc.vv.device.stopRecording();
        this.stop_time = new Date().getTime();
    },
    async processAudioData(audioChunks) {
        const audioBlob = new Blob(audioChunks, { type: "audio/wav" });
   
        const audioContext = new AudioContext();
        const reader = new FileReader();
   
        reader.readAsArrayBuffer(audioBlob);
        reader.onloadend = async () => {
            try {
                const audioBuffer = await audioContext.decodeAudioData(reader.result);
                const sampleRate = 44100; // 统一降采样
                const mp3Encoder = new lamejs.Mp3Encoder(1, sampleRate, 128);
   
                // 修正 samples 计算
                const samples = new Int16Array(audioBuffer.getChannelData(0).map(n => Math.round(n * 32767)));
   
                const mp3Data = [];
                const sampleBlockSize = 1152;
   
                for (let i = 0; i < samples.length; i += sampleBlockSize) {
                    const sampleBlock = samples.subarray(i, Math.min(i + sampleBlockSize, samples.length));
                    const mp3Block = mp3Encoder.encodeBuffer(sampleBlock);
                    mp3Data.push(mp3Block);
                }
   
                const finalMp3Block = mp3Encoder.flush();
                mp3Data.push(finalMp3Block);
   
                const mp3Blob = new Blob(mp3Data, { type: 'audio/mp3' });
                let duration = this.stop_time - this.start_time;
                // cc.error("duration",duration);
                // 转换 Base64
                this.convertBlobToBase64(mp3Blob,(base64Audio)=>{
                    // cc.error("MP3 Base64 编码:", base64Audio);
                    // 发送数据
                    if (cc.vv.gameRequest) {
                        const chunkSize = 51200;
                        if (base64Audio.length > chunkSize) {
                            let chunks = Math.ceil(base64Audio.length / chunkSize);
                            for (let i = 0; i < chunks; i++) {
                                let chunk = base64Audio.slice(i * chunkSize, (i + 1) * chunkSize);
                                cc.vv.gameRequest.sendChatReq(4, "", duration, chunk, i + "", chunks);
                                // cc.error("Sending chunk: ", i + 1, " / ", chunks);
                            }
                        } else {
                            cc.vv.gameRequest.sendChatReq(4, "", duration, base64Audio);
                        }
                    }
   
                });
            } catch (error) {
                console.error("解码音频数据失败:", error);
            }
        };
    },
   
   
    /**
     * 将 Blob 转换为 Base64
     */
    convertBlobToBase64(blob, callback) {
        const reader = new FileReader();
        reader.readAsDataURL(blob);
        reader.onloadend = () => {
            const base64String = reader.result.split(',')[1]; // 去掉 Data URL 头部信息
            callback(base64String);
        };
    },
       
    // 下载 MP3 文件
    downloadMp3(mp3Blob) {
        const mp3Url = URL.createObjectURL(mp3Blob);
        const downloadLink = document.createElement('a');
        downloadLink.href = mp3Url;
        downloadLink.download = 'audio.mp3';
        downloadLink.click();
    },
    // 播放 MP3
    playMp3(mp3Data) {
        if (!mp3Data || !(mp3Data instanceof Blob)) {
            console.error("MP3 数据无效:", mp3Data);
            return;
        }
        const audioURL = URL.createObjectURL(mp3Data);
        const audio = new Audio(audioURL);
        audio.play();
    },
   
    showView(){
        this.node.active = true;
    },
    // Base64 转 Uint8Array
    base64ToUint8Array(base64Str) {
        let binary = atob(base64Str);
        let len = binary.length;
        let bytes = new Uint8Array(len);
        for (let i = 0; i < len; i++) {
            bytes[i] = binary.charCodeAt(i);
        }
        return bytes;
    },
    onItemClicked(event, type){
        let text = event.target.info;
        this.sendChat(text);
        this.hidePanel();
       
    },
    onBtnClicked(event, type) {
        if (cc.vv.utils.allowclick(0.3) == false) {
            return;
        }
        let name = event.target.name;
        if (name == "backbtn") {
            this.hidePanel();
        } else if (name == "btn_send") {
            let text = this.editBox.string;
            if (text) {
                // let test = "";
                // for (let index = 0; index < 1024*50; index++) {
                //     test +="A";
                // }
                // cc.error("test",test.length);
                // 51200
                if (text.length > 15) {
                    require("Tips").show("老板,发送内容太长了,请修改!");
                    return;
                }
                cc.vv.gameRequest.sendChatReq(1, text);
                this.editBox.string = "";
                this.hidePanel();
            } else {
                require("Tips").show("请输入要发送的文字...");
            }
        }
    },
    sendChat(text){
        cc.vv.gameRequest.sendChatReq(1, text);
    },
    onDestroy() {
        if (signal) signal.targetOff(this);
        this.chatItem.destroy();
        if (this.start_voice) {
            this.stopRecording();
        }
    },
});

cocosCreator 播放语音代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
cc.Class({
    extends: cc.Component,
    properties: {
        item : cc.Node,
        listArr : {
            default : [],
            type : cc.Node,
         },
 
    },
    // LIFE-CYCLE CALLBACKS:
    onLoad () {
        signal.on("chat_ntf",this.playChat.bind(this),this);
        this.item.removeFromParent();
    },
    start () {
        this.chunkList = [];
    },
    maskString(inputString) {
        if (cc.vv.global.FISH_TXTS.includes(inputString)) {
            return inputString;
        }
        // 使用正则表达式匹配数字和字母
        return inputString.replace(/[a-zA-Z0-9]/g, '*');
    },
    playChat(data){
        if (data.chat_type == 1) {
            let parent = this.findNodeWithLeastChildren();
            let chat = cc.instantiate(this.item);
            chat.setPosition(cc.v2(850,0));
            let text = chat.getChildByName("text");
            let content = this.maskString(data.content);
            text.getComponent(cc.RichText).string = cc.vv.utils.format("<color=#ffeb8f>{0}:</c><color=#e9f9ff>{1}</color>",[cc.vv.utils.formatUsername(data.nickname),content]);
            chat.parent = parent;
            chat.getComponent(cc.Layout).updateLayout();
            let width = chat.width;
            let beginPos = chat.getPosition();
            let endPos = cc.v2(-(850+width),0);
            let time = beginPos.sub(endPos).mag() / 300;
            // cc.error("width",width,time,endPos.x);
            chat.runAction(cc.sequence([cc.moveTo(time, endPos) , cc.removeSelf()]));
        } else if(data.chat_type == 4) {
            // if (data.player_id == cc.vv.userMgr.player_id) {
            //     return;
            // }
            if (data.emoji && data.expand !="") {
                //要拼接
                let info = {player_id:data.player_id, idx:parseInt(data.expand) , voice_content:data.voice_content,cnt :data.emoji };
                this.chunkList.push(info);
                let count = 0;
                let isok = false;
                let voice_list = [];
                for (let index = 0; index < this.chunkList.length; index++) {
                    const element = this.chunkList[index];
                    if (element.player_id == data.player_id) {
                        count++;
                        voice_list.push(element);
                    }
                    if (count == data.emoji ) {
                        isok = true;
                        break;
                    }
                }
                let audioBase64 = "";
                if (isok) {
                    voice_list.sort((a, b) => a.idx - b.idx);
                    voice_list.forEach((item) => {
                        // 假设每个 `item.voice_content` 是一个 Base64 编码的音频数据
                        audioBase64 += item.voice_content;
                    });
                    this.playVice(data,audioBase64);
                }
            } else {
                this.playVice(data,data.voice_content);
            }
        }
    },
    // Base64 转 Uint8Array
    base64ToUint8Array(base64Str) {
        let binary = atob(base64Str);
        let len = binary.length;
        let bytes = new Uint8Array(len);
        for (let i = 0; i < len; i++) {
            bytes[i] = binary.charCodeAt(i);
        }
        return bytes;
    },
    playVice(data,audioBase64){
        // cc.error("chunkList",this.chunkList);
         // 播放后清除 this.chunkList 中 player_id 等于 data.player_id 的项
        this.chunkList = this.chunkList.filter(item => item.player_id !== data.player_id);
        let bgm_v = cc.vv.audioMgr.bgmVolume;
        let sfx_v = cc.vv.audioMgr.sfxVolume;
        let val = 0.2;
        if (bgm_v > val) {
            cc.vv.audioMgr.setBGMVolume(val);
        }
        if (sfx_v > val) {
            cc.vv.audioMgr.setSFXVolume(val);
        }
        let time = Number(data.voice_time)/1000;
        this.scheduleOnce(()=>{
            cc.vv.audioMgr.setBGMVolume(bgm_v);
            cc.vv.audioMgr.setSFXVolume(sfx_v);
        },time);
        signal.emit("show_voice",data);
        if (cc.sys.isBrowser) {
            // cc.error("audioBase64",audioBase64);
            let audio = new Audio();
            audio.src = "";
            // 创建 base64 数据 URL
            let audioDataUrl = "data:audio/mp3;base64," + audioBase64;
            // 设置音频源为 Base64 数据
            audio.src = audioDataUrl;
            audio.volume = cc.vv.audioMgr.voiceVolume;
            // 播放音频
            audio.play()
                .then(() => {
                    console.log("Audio playing...");
                })
                .catch((error) => {
                    console.error("Audio playback failed:", error);
                });
        } else {
            var byteArray = this.base64ToUint8Array(audioBase64);
            // 获取可写路径
            var dirPath = jsb.fileUtils.getWritablePath() + cc.vv.global.PLAY_AUDIO+"/";
   
            // 判断目录是否存在
            if (!jsb.fileUtils.isDirectoryExist(dirPath)) {
                // 创建目录
                jsb.fileUtils.createDirectory(dirPath);
            }
            // 生成完整文件路径
            var filePath = dirPath + "recorded_audio_" + data.id + ".mp3";
            console.log("音频文件路径: ", filePath);
            // 保存到本地文件
            var success = jsb.fileUtils.writeDataToFile(byteArray, filePath);
   
            if (success) {
                console.log("Audio saved successfully to:", filePath);
   
                // 播放保存的音频文件
                cc.loader.load(filePath, function (err, audioClip) {
                    if (err) {
                        console.error("Audio load failed:", err);
                        return;
                    }
   
                    console.log("Audio loaded successfully");
                    // 播放音频
                    cc.audioEngine.play(audioClip, false, cc.vv.audioMgr.voiceVolume);
                    // 播放完后删除文件
                    // jsb.fileUtils.removeFile(filePath);
                });
            }
        }
        // cc.error("chunkList2",this.chunkList);
    },
    findNodeWithLeastChildren() {
        let minChildrenNode = null;
        let minChildrenCount = Infinity;
        // 遍历 listArr,找到子节点最少的节点
        this.listArr.forEach(node => {
            let childrenCount = node.children.length;  // 获取该节点的子节点数量
            if (childrenCount < minChildrenCount) {
                minChildrenCount = childrenCount;
                minChildrenNode = node;
            }
        });
        return minChildrenNode;  // 返回子节点最少的节点
    },
    onDestroy() {
        if (signal) signal.targetOff(this);
        this.item.destroy();
    },
    // update (dt) {},
});

调用原生代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

    startRecording() {
        let javaMethodName = this.getAndroidMethodName("startRecording");
        if (cc.sys.isNative && cc.sys.os == cc.sys.OS_ANDROID && jsb && jsb.reflection && jsb.reflection.callStaticMethod) {
            jsb.reflection.callStaticMethod(this.ANDROID_API, javaMethodName, "()V");
        } else if (cc.sys.isNative && cc.sys.os == cc.sys.OS_IOS) {
            jsb.reflection.callStaticMethod(this.IOS_AIP,javaMethodName);
        } else if (cc.sys.isBrowser) {
           
        }
    },
    stopRecording() {
        let javaMethodName = this.getAndroidMethodName("stopRecording");
        if (cc.sys.isNative && cc.sys.os == cc.sys.OS_ANDROID && jsb && jsb.reflection && jsb.reflection.callStaticMethod) {
            jsb.reflection.callStaticMethod(this.ANDROID_API, javaMethodName, "()V");
        } else if (cc.sys.isNative && cc.sys.os == cc.sys.OS_IOS) {
            jsb.reflection.callStaticMethod(this.IOS_AIP,javaMethodName);
        } else if (cc.sys.isBrowser) {
           
        }
    },

原生回调cocosCreator

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Uint8Array 转 Base64
uint8ArrayToBase64(u8Arr) {
let binary = '';
let len = u8Arr.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(u8Arr[i]);
}
return btoa(binary); // 使用 Base64 编码
},
onAudioDataReceived(audioFilePath,audioTimeStamp,duration){
cc.error( "onAudioDataReceived",audioFilePath,audioTimeStamp,duration);
if (duration < 1000) {
require("Tips").show("老板,说话时间太短,请重新发送!");
return;
}
if (audioFilePath) {
if (cc.vv.resMgr.getCurSceneName() != SCENES_TYPE.FISHGAME) {
cc.error("not in game ");
return;
}
var audioPath = decodeURIComponent(audioFilePath);

// cc.error("audioPath",audioPath);
if (cc.sys.isNative && jsb && jsb.reflection) {
let audioData = jsb.fileUtils.getDataFromFile(audioPath);
if (!audioData) {
cc.error("Failed to read audio file");
return;
}

let base64Audio = this.uint8ArrayToBase64(audioData);
// cc.error("Base64 Audio: ", base64Audio);
cc.error("len: ", base64Audio.length);
// 如果 Base64 长度超过 51200 字节,就分割成多个块发送
if (cc.vv.gameRequest) {
const chunkSize = 51200;
if (base64Audio.length > chunkSize) {
let chunks = Math.ceil(base64Audio.length / chunkSize);
for (let i = 0; i < chunks; i++) {
let chunk = base64Audio.slice(i * chunkSize, (i + 1) * chunkSize);
cc.vv.gameRequest.sendChatReq(4, "", duration, chunk, i+"",chunks);
cc.error("Sending chunk: ", i + 1, " / ", chunks);
}
} else {
cc.vv.gameRequest.sendChatReq(4, "", duration, base64Audio);
}
}

// 删除 audioPath 文件
if (jsb.fileUtils.isFileExist(audioPath)) {
jsb.fileUtils.removeFile(audioPath);
cc.error("File deleted: ", audioPath);
} else {
cc.error("File not found, unable to delete: ", audioPath);
}
}
}
},

pb协议
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
message ChatReq{
optional uint32 chat_type = 1;//1:普通聊天2:邀请房间游戏3:emoji 4:语音
optional string expand = 2;
optional string nickname = 3;
optional string content = 4;
optional uint32 emoji = 5;//表情
optional int32 voice_time = 6;//语音时间
optional string voice_content = 7;//语音
}

message ChatRes{
optional uint32 chat_type = 1;
optional uint32 id = 2;
}

message ChatNtf{
optional uint32 player_id = 1;
optional uint32 seat_id = 2;
optional uint32 chat_type = 3;
optional string expand = 4;
optional string nickname = 5;
optional string content = 6;
optional uint32 emoji = 7;//表情
optional int32 voice_time = 8;//语音时长
optional string voice_content = 9;//语音
optional string face_url = 10;
optional int32 time = 11;//时间
optional int32 id = 12;//维一id
}

Android 代码

权限

1
2
3
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.RECORD_AUDIO"/>

java代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
 private static MediaRecorder mediaRecorder;
private static RecorderFactory mFactory;
private static Recorder mRecorder;
private static File audioFile;
private static long startTime;
private static long endTime;
private static String audioTimeStamp = "";
public static AppActivity activity;
public static void init(AppActivity appActivity){
activity = appActivity;
mFactory = new RecorderFactory();
mRecorder = mFactory.newRecorder(RecorderModel.RECORDER_MODEL_MP3);
}
// 录音开始
public static void startRecording() {
try {
if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.RECORD_AUDIO}, Constants.REQUEST_PERMISSION_CODE);
} else {
// 创建一个保存录音的文件
File dir = new File(activity.getFilesDir(), "audio_recordings");
if (!dir.exists()) {
dir.mkdirs(); // 确保目录存在
}
// 生成带时间戳的文件名
String timeStamp = String.valueOf(System.currentTimeMillis());
audioTimeStamp = timeStamp;
String fileName = "audio_" + timeStamp + ".mp3";
audioFile = new File(dir, fileName);
mRecorder.prepare(audioFile);
new Thread(mRecorder::start).start();
// 记录录音的起始时间
startTime = System.currentTimeMillis();
}
} catch (IOException e) {
e.printStackTrace();
}
}

// 录音停止
public static void stopRecording() {
mRecorder.stop();
endTime = System.currentTimeMillis();
String audioFilePath = audioFile.getAbsolutePath();
sendAudioDataToCocosCreator(audioFilePath);
}
// 获取录音时长(单位:秒)
public static int getRecordingDuration() {
if (startTime == 0 || endTime == 0) {
return 0; // 如果没有正确录音,返回 0
}
return (int)(endTime - startTime); // 时长单位为秒
}

// 将音频数据传回给 Cocos Creator
@SuppressLint("LongLogTag")
private static void sendAudioDataToCocosCreator(String audioData) {
String encodedAudioData = Uri.encode(audioData);
activity.runOnGLThread(new Runnable() {
@Override
public void run() {
Cocos2dxJavascriptJavaBridge.evalString("JavascriptWindow.onAudioDataReceived('"+ encodedAudioData +"', '"+ audioTimeStamp+"' ,'"+getRecordingDuration()+"')");
}
});
}

IOS端代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217

.h
+ (void)startRecording;
+ (void)stopRecording;

@property (nonatomic, strong) NSString *audioFilePath;
@property (nonatomic, strong) AVAudioRecorder *audioRecorder;
@property (nonatomic, assign) long long startTime;
@property (nonatomic, assign) long long endTime;

.mm
#import "cocos/base/CCScheduler.h"
#import "cocos/scripting/js-bindings/jswrapper/SeApi.h"
#import "cocos/platform/CCApplication.h"
#import "lame.h"


+ (void)startRecording {
AVAudioSession *session = [AVAudioSession sharedInstance];
NSError *error2 = nil;

// 设置音频会话类别为录音模式
[session setCategory:AVAudioSessionCategoryPlayAndRecord
withOptions:AVAudioSessionCategoryOptionDuckOthers
error:&error2];


if (error2) {
NSLog(@"音频会话设置失败: %@", error2.localizedDescription);
}

[session setActive:YES error:&error2];
if (error2) {
NSLog(@"激活音频会话失败: %@", error2.localizedDescription);
}
// 获取沙盒目录路径
NSString *dirPath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"audio_recordings"];
[[NSFileManager defaultManager] createDirectoryAtPath:dirPath
withIntermediateDirectories:YES
attributes:nil
error:nil];
// 获取目录下的所有文件
NSArray *files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dirPath error:nil];

for (NSString *file in files) {
NSString *filePath = [dirPath stringByAppendingPathComponent:file];
[[NSFileManager defaultManager] removeItemAtPath:filePath error:nil];
}

// 生成带时间戳的文件名
NSString *timeStamp = [NSString stringWithFormat:@"%d", (int)[[NSDate date] timeIntervalSince1970]];
NSString *fileName = [NSString stringWithFormat:@"audio_%@.caf", timeStamp];
[self sharedInstance].audioFilePath = [dirPath stringByAppendingPathComponent:fileName];

NSDictionary *settings = @{
AVFormatIDKey: @(kAudioFormatLinearPCM),
AVSampleRateKey: @(44100), // 采样率
AVNumberOfChannelsKey: @(1), // 单声道
AVLinearPCMBitDepthKey:@16,
AVEncoderAudioQualityKey: @(AVAudioQualityLow) // 最高质量
};

NSError *error = nil;
[self sharedInstance].audioRecorder = [[AVAudioRecorder alloc] initWithURL:[NSURL fileURLWithPath:[self sharedInstance].audioFilePath]
settings:settings
error:&error];

if (error) {
NSLog(@"录音初始化失败: %@", error.localizedDescription);
return;
}

[[self sharedInstance].audioRecorder prepareToRecord];
// [self sharedInstance].audioRecorder.meteringEnabled = YES;
[[self sharedInstance].audioRecorder record]; // 开始录音
NSLog(@"开始录音...");
// NSString *mp3FileName = [NSString stringWithFormat:@"%@.mp3", fileName];
[self sharedInstance].startTime = [[NSDate date] timeIntervalSince1970]*1000;
}

+ (void)stopRecording {
[[self sharedInstance].audioRecorder stop];
[self sharedInstance].endTime = [[NSDate date] timeIntervalSince1970]*1000;
// NSLog(@"endTime: %d",[self sharedInstance].endTime);
NSLog(@"audioFilePath: %@",[self sharedInstance].audioFilePath);
// NSURL *audioFileURL = [NSURL fileURLWithPath:[self sharedInstance].audioFilePath];



// PCM 转 MP3
NSString *mp3Path = [[[self sharedInstance].audioFilePath stringByDeletingPathExtension] stringByAppendingPathExtension:@"mp3"];
[self convertPCMToMP3:[self sharedInstance].audioFilePath mp3FilePath:mp3Path];
// NSError *error;
//
// NSURL *fileURL = [NSURL fileURLWithPath:mp3Path];
//
// AVAudioPlayer *audioPlayer = [[AVAudioPlayer alloc] initWithContentsOfURL:fileURL error:&error];
// if (error) {
// NSLog(@"播放录音文件出错: %@", error.localizedDescription);
// } else {
// [audioPlayer prepareToPlay];
// [audioPlayer play];
// NSLog(@"正在播放录音...");
// }

// dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
//// [self convertPCMToMP3:[self sharedInstance].audioFilePath mp3FilePath:mp3Path];
//
// });


// 发送音频数据到 Cocos Creator
[self sendAudioDataToCocosCreator:mp3Path];
// [self restoreAudioSession];

AVAudioSession *session = [AVAudioSession sharedInstance];
NSError *error = nil;

// 录音结束后恢复到仅播放模式,并切换到扬声器
[session setCategory:AVAudioSessionCategoryPlayback
withOptions:AVAudioSessionCategoryOptionMixWithOthers
error:&error];

if (error) {
NSLog(@"恢复音频模式失败: %@", error.localizedDescription);
}

// 强制使用扬声器播放
[session overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker error:&error];
if (error) {
NSLog(@"切换扬声器失败: %@", error.localizedDescription);
}

[session setActive:YES error:&error];
if (error) {
NSLog(@"重新激活音频会话失败: %@", error.localizedDescription);
}
}



+ (void)convertPCMToMP3:(NSString *)pcmFilePath mp3FilePath:(NSString *)mp3FilePath {
FILE *pcm = fopen([pcmFilePath cStringUsingEncoding:NSUTF8StringEncoding], "rb");
FILE *mp3 = fopen([mp3FilePath cStringUsingEncoding:NSUTF8StringEncoding], "wb");

if (!pcm || !mp3) {
NSLog(@"文件打开失败!");
return;
}

const int PCM_SIZE = 8192;
const int MP3_SIZE = 8192;
short int pcmBuffer[PCM_SIZE * 2]; // 适用于立体声
unsigned char mp3Buffer[MP3_SIZE];

lame_t lame = lame_init();
lame_set_in_samplerate(lame, 44100); // 采样率 44100Hz
lame_set_num_channels(lame, 1); // 设置单声道(如果是立体声则改为 2)
// lame_set_brate(lame, 16); // 设置比特率 128kbps
// lame_set_mode(lame, MONO); // 设为单声道模式(如果是双声道,可用 JOINT_STEREO)
// lame_set_quality(lame, 5); // 质量 2(高质量,但编码速度较慢)
lame_set_VBR(lame, vbr_default);
lame_init_params(lame);
int read, write;

do {
read = fread(pcmBuffer, sizeof(short int), PCM_SIZE, pcm);
if (read == 0) {
write = lame_encode_flush(lame, mp3Buffer, MP3_SIZE);
} else {
write = lame_encode_buffer(lame, pcmBuffer, NULL, read, mp3Buffer, MP3_SIZE);
}
fwrite(mp3Buffer, write, 1, mp3);
} while (read != 0);

lame_close(lame);
fclose(mp3);
fclose(pcm);

NSLog(@"转换完成: %@", mp3FilePath);
}


+ (int)getRecordingDuration {
if ([self sharedInstance].startTime == 0 || [self sharedInstance].endTime == 0) {
return 0;
}
return (int)([self sharedInstance].endTime - [self sharedInstance].startTime);
}


+ (void)sendAudioDataToCocosCreator:(NSString *)audioData {
if (!audioData || audioData.length == 0) {
NSLog(@"Error: audioData is nil or empty.");
return;
}

NSString *encodedAudioData = [audioData stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]];
if (!encodedAudioData) {
NSLog(@"Error: encodedAudioData is nil.");
return;
}

NSString *audioFilePath = [self sharedInstance].audioFilePath ?: @"";
int duration = [self getRecordingDuration];

NSString *jsCode = [NSString stringWithFormat:@"JavascriptWindow.onAudioDataReceived('%@', '%@', '%d');", encodedAudioData, audioFilePath, duration];

// 这里先不创建 std::string
NSLog(@"jsCode: %@", jsCode);

cocos2d::Application::getInstance()->getScheduler()->performFunctionInCocosThread([jsCodeCopy = [jsCode copy]]() mutable {
std::string jsCodeStr = [jsCodeCopy UTF8String]; // 在 lambda 里创建 std::string,避免生命周期问题
se::ScriptEngine::getInstance()->evalString(jsCodeStr.c_str());
});
}