增加声音克隆oss批量

main
guofei 2024-12-02 20:12:22 +08:00
parent 9e7d3137e6
commit cfb38ccf8b
18 changed files with 1536 additions and 9 deletions

View File

@ -1 +1,29 @@
pm2 start --name admin-banban-new-nest npm -- run start:prod
// {
// roleId: '6704bd0ef48326fe51ddb751',
// roleName: '甘宁',
// url: 'https://banban-systemcharter-speak.oss-cn-beijing.aliyuncs.com/test/%E4%BB%A3%E5%8F%B7%E9%B8%A2%E5%AF%86%E6%8E%A2%E7%94%98%E5%AE%81.mp3',
// taskId: '674d85d7467ce26d974f97e9'
// }
// 开始处理任务 [第1次尝试]: 甘宁
// {
// SpeakerID: 'S*FC60x0Gb1',
// InstanceNO: 'Model_storage_BpVrslAkhpaKVod*',
// IsActivatable: false,
// State: 'Unknown',
// DemoAudio: null,
// Version: null,
// CreateTime: 0,
// ExpireTime: 1764691199000,
// Alias: '',
// AvailableTrainingTimes: 10
// }
// S_FC60x0Gb1
开始克隆声音,参数: { speaker_id: 'S_FC60x0Gb1', appid: '8167092294', audio_format: 'wav' }
<!-- BaseResp: {
StatusCode: 1114,
StatusMessage: 'snr check failed, snr: 0.33, threshold: 5.00'
}, -->

View File

@ -9,6 +9,7 @@
"build": "nest build",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"start": "nest start",
"dev": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
@ -21,15 +22,22 @@
"gen": "prisma generate"
},
"dependencies": {
"@nestjs/bull": "^10.2.3",
"@nestjs/common": "^10.0.0",
"@nestjs/core": "^10.0.0",
"@nestjs/platform-express": "^10.0.0",
"@prisma/client": "^5.19.1",
"@volcengine/openapi": "^1.25.0",
"ali-oss": "^6.21.0",
"axios": "^1.7.8",
"bull": "^4.16.4",
"class-validator": "^0.14.1",
"ioredis": "^5.4.1",
"lodash": "^4.17.21",
"prisma": "^5.19.1",
"reflect-metadata": "^0.2.0",
"rxjs": "^7.8.1"
"rxjs": "^7.8.1",
"uuid": "^11.0.3"
},
"devDependencies": {
"@nestjs/cli": "^10.0.0",

File diff suppressed because it is too large Load Diff

View File

@ -26,4 +26,35 @@ model SystemCharter {
bg String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 音色id
voiceId String?
// 音色名称
voiceName String?
// 原始音频
originAudioUrl String?
// 克隆音频
cloneAfterAudioUrl String?
// 是否激活
activate Boolean @default(false)
}
// 任务队列
model TaskQueue {
id String @id @default(auto()) @map("_id") @db.ObjectId
// 任务类型
type String
// 任务数据
data String
// 任务状态: pending/processing/completed/failed
status String
// 失败原因
error String?
// 重试次数
attempts Int @default(0)
// 最大重试次数
maxAttempts Int @default(3)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -3,11 +3,11 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { SystemCharterModule } from './modules/index';
import { DBModule } from './utils/db/DB.module';
import { RedisTask } from './common/RedisTask/redisTask.module';
@Module({
imports: [DBModule, SystemCharterModule],
imports: [DBModule, SystemCharterModule, RedisTask],
controllers: [AppController],
providers: [AppService],
})
export class AppModule { }
export class AppModule {}

View File

@ -0,0 +1,34 @@
import { Global, Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { RedisTaskProcessor } from './RedisTaskProcessor.service';
import { RedisTaskService } from './RedisTask.service';
@Global()
@Module({
imports: [
BullModule.forRoot({
redis: {
host: '47.101.147.173',
port: 6379,
password: 'SAe7HmZhkF8uev',
},
defaultJobOptions: {
removeOnComplete: true,
attempts: 3,
},
}),
BullModule.registerQueue({
name: 'task_queue',
limiter: {
max: 1,
duration: 1000,
},
}),
BullModule.registerQueue({
name: 'dead_letter_queue',
}),
],
providers: [RedisTaskService, RedisTaskProcessor],
exports: [RedisTaskService],
})
export class RedisTask {}

View File

@ -0,0 +1,94 @@
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Queue } from 'bull';
import { DBService } from 'src/utils/db/DB.service';
@Injectable()
export class RedisTaskService {
constructor(
@InjectQueue('task_queue') private readonly taskQueue: Queue,
private readonly dbService: DBService,
) {}
// 添加任务
async addTask(data: any) {
// 先记录到数据库
const taskRecord = await this.dbService.taskQueue.create({
data: {
type: 'BATCH_CLONE_AUDIO',
data: JSON.stringify(data),
status: 'pending',
},
});
// 添加到Redis队列
await this.taskQueue.add(
'BATCH_CLONE_AUDIO',
{
...data,
taskId: taskRecord.id,
},
{
removeOnComplete: true,
attempts: 3,
},
);
}
// 获取所有任务
async getTasks(status?: string) {
const where = status ? { status } : {};
return await this.dbService.taskQueue.findMany({
where,
orderBy: {
createdAt: 'desc',
},
});
}
// 更新任务状态
async updateTaskStatus(taskId: string, status: string, error?: string) {
return await this.dbService.taskQueue.update({
where: { id: taskId },
data: {
status,
error,
attempts: { increment: 1 },
},
});
}
// 重试失败的任务
async retryTask(taskId: string) {
const task = await this.dbService.taskQueue.findUnique({
where: { id: taskId },
});
if (task && task.status === 'failed') {
// 重置任务状态
await this.dbService.taskQueue.update({
where: { id: taskId },
data: {
status: 'pending',
attempts: 0,
error: null,
},
});
// 重新加入队列
await this.taskQueue.add(
'BATCH_CLONE_AUDIO',
{
...JSON.parse(task.data),
taskId: task.id,
},
{
removeOnComplete: true,
attempts: 3,
},
);
return true;
}
return false;
}
}

View File

@ -0,0 +1,69 @@
import { Process, Processor } from '@nestjs/bull';
import { Job } from 'bull';
import { RedisTaskService } from './RedisTask.service';
import { VolcenAudioSpeakService } from 'src/services/VolcenAudioSpeakService';
import { CloneSpeakDto } from 'src/modules/SystemCharter/dto/CloneSpeakDto.dto';
import { DBService } from 'src/utils/db/DB.service';
@Processor('task_queue')
export class RedisTaskProcessor {
constructor(
private readonly redisTaskService: RedisTaskService,
private readonly dbService: DBService,
) {}
@Process('BATCH_CLONE_AUDIO')
async handleSpecificTask(job: Job) {
let voiceId;
try {
const charterInfo = job.data as CloneSpeakDto;
const { taskId, url: ossUrl } = charterInfo;
if (taskId) {
await this.redisTaskService.updateTaskStatus(taskId, 'processing');
}
console.log(job.data);
console.log(`开始处理任务 [第${job.attemptsMade + 1}次尝试]: ${job.data.roleName}`);
const volcenAudioSpeakService = new VolcenAudioSpeakService();
// voiceId = await volcenAudioSpeakService.getVoiceId();
// const voiceId = 'S_FC60x0Gb1';
const voiceId = 'S_VK2Yw0Gb1';
// 请求ossurl获取base64
const base64 = await volcenAudioSpeakService.getAudioBase64(ossUrl);
// 克隆音频
volcenAudioSpeakService.speakClone(voiceId, base64);
// // 5秒后激活
setTimeout(async () => {
if (taskId) {
await this.redisTaskService.updateTaskStatus(taskId, 'completed');
// 激活音频 (激活后就不能克隆了,所以要克隆后确定后再激活)
// await volcenAudioSpeakService.speakActivate(voiceId);
const textToSpeechResult = await volcenAudioSpeakService.textToSpeech(
'今天有什么新鲜事吗?快给我讲讲',
voiceId,
);
// 修改系统角色的 voiceId
await this.dbService.systemCharter.update({
where: { id: charterInfo.roleId },
data: {
voiceId,
voiceName: charterInfo.roleName,
// 是否激活
activate: true,
originAudioUrl: ossUrl,
cloneAfterAudioUrl: textToSpeechResult.url,
},
});
}
console.log(`任务处理完成: ${job.data.roleName}`);
}, 1000 * 5);
} catch (error) {
console.log(error.data);
console.log('voiceId=--------------', voiceId);
console.error(`任务处理失败 [第${job.attemptsMade + 1}次尝试]: ${error.message}`);
if (job.data.taskId) {
await this.redisTaskService.updateTaskStatus(job.data.taskId, 'failed', error.message);
}
}
}
}

View File

@ -3,11 +3,16 @@ import { SystemCharterlDto } from './dto/SystemCharter.dto';
import { Pagination } from 'src/common/pagination';
import { DBService } from 'src/utils/db/DB.service';
import { ApiResponse } from 'src/utils/response/response';
import { RedisTaskService } from 'src/common/RedisTask/RedisTask.service';
import { CloneSpeakDto } from './dto/CloneSpeakDto.dto';
import axios from 'axios';
@Controller('/system/charter')
export class SystemCharterController {
constructor(private readonly dbService: DBService) { }
constructor(
private readonly dbService: DBService,
private readonly redisTaskService: RedisTaskService,
) {}
@Get('/getList')
async getList(@Query() query: SystemCharterlDto) {
@ -52,4 +57,53 @@ export class SystemCharterController {
});
return newRecord;
}
@Get('cloneSpeaker')
async batchCloneAudio(@Body() object: CloneSpeakDto) {
// try {
// if (!object.roleId || !object.roleName || !object.url) {
// return ApiResponse.failToMessage('参数错误');
// }
// // 验证音频URL是否可访问
// const response = await axios.head(object.url);
// if (response.status !== 200) {
// return ApiResponse.failToMessage('音频文件无法访问');
// }
// // 验证文件类型
// const contentType = response.headers['content-type'];
// if (!contentType.includes('audio')) {
// return ApiResponse.failToMessage('文件类型必须是音频');
// }
// this.redisTaskService.addTask(object);
// return ApiResponse.success(null, '任务添加成功');
// } catch (error) {
// console.error('添加克隆任务失败:', error);
// return ApiResponse.failToMessage('添加任务失败:' + error.message);
// }
}
@Get('test')
async batchCloneAudio1() {
const result = [
{
roleId: '6704bd0ef48326fe51ddb751',
roleName: '甘宁',
// url: 'https://banban-systemcharter-speak.oss-cn-beijing.aliyuncs.com/test/%E4%BB%A3%E5%8F%B7%E9%B8%A2%E5%AF%86%E6%8E%A2%E5%91%A8%E7%91%9C.mp3',
// url: 'https://banban-systemcharter-speak.oss-cn-beijing.aliyuncs.com/test/%E4%BB%A3%E5%8F%B7%E9%B8%A2%E5%AF%86%E6%8E%A2%E7%94%98%E5%AE%81.mp3',
// url: 'https://banban-systemcharter-speak.oss-cn-beijing.aliyuncs.com/test/%E4%BB%A3%E5%8F%B7%E9%B8%A2%E5%AF%86%E6%8E%A2%E7%94%98%E5%AE%81%20-%20%E5%89%AF%E6%9C%AC.wav',
// 郭德纲
// url: 'https://banban-systemcharter-speak.oss-cn-beijing.aliyuncs.com/test/%E9%83%AD%E5%BE%B7%E7%BA%B2-%E5%A3%B0%E9%9F%B3%E5%85%8B%E9%9A%86.mp3',
// 增以后的
url: 'https://banban-systemcharter-speak.oss-cn-beijing.aliyuncs.com/test/WeChat_20241119150807_1.mp3',
// 周瑜
// url: 'https://banban-systemcharter-speak.os-cn-beijing.aliyuncs.com/test/%E4%BB%A3%E5%8F%B7%E9%B8%A2%E5%AF%86%E6%8E%A2%E5%91%A8%E7%91%9C.mp3',
// 周瑜 提高分贝后
// url: 'https://banban-systemcharter-speak.oss-cn-beijing.aliyuncs.com/test/%E5%91%A8%E7%91%9C.mp3',
},
];
result.forEach((item) => {
this.redisTaskService.addTask(item);
});
}
}

View File

@ -1,10 +1,21 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { SystemCharterController } from './SystemCharter.controller';
import { SystemCharterService } from './SystemCharter.servicer';
import { RedisTaskService } from 'src/common/RedisTask/RedisTask.service';
@Module({
imports: [],
imports: [
BullModule.registerQueue(
{
name: 'task_queue',
},
{
name: 'dead_letter_queue',
},
),
],
controllers: [SystemCharterController],
providers: [SystemCharterService],
providers: [SystemCharterService, RedisTaskService],
})
export class SystemCharterModule {}

View File

@ -0,0 +1,20 @@
import { IsOptional, IsString } from 'class-validator';
export class CloneSpeakDto {
// 任务id
@IsString()
@IsOptional()
taskId?: string;
// 角色id
@IsString()
roleId: string;
// 角色名称
@IsString()
roleName: string;
// 音频url
@IsString()
url: string;
}

View File

@ -0,0 +1,63 @@
import 'dotenv/config';
import { v4 as uuid } from 'uuid';
import axios from 'axios';
export const TTSTiktokService =
(isCustom = false, backupVoiceName?: string) =>
async (text: string, onComplete: (audioData: Buffer) => Promise<any>, voiceName: string) => {
const body = {
app: {
appid: process.env.TiktokAppId,
token: 'default_token',
// cluster: "volcano_tts",
cluster: isCustom ? 'volcano_mega' : 'volcano_tts',
},
user: {
uid: 'volcano_user',
},
audio: {
voice_type: voiceName,
encoding: 'pcm',
emotion: 'happy',
language: 'cn',
rate: 16000,
},
request: {
reqid: uuid(),
text: text,
text_type: 'plain',
operation: 'query',
pure_english_opt: '1',
},
};
return new Promise((resolve, reject) => {
axios
.post(`https://openspeech.bytedance.com/api/v1/tts`, body, {
headers: {
Authorization: `Bearer;${process.env.TiktokAccessToken}`,
'Content-Type': 'application/json',
},
method: 'POST',
})
.then(async (res) => {
const base64Data = res.data.data;
const buffer = Buffer.from(base64Data, 'base64');
await onComplete(buffer);
resolve(true);
})
.catch(async (e) => {
// 若是自定义语音出现问题,比如超过并发,则使用备用语音
if (isCustom) {
try {
await TTSTiktokService(false)(text, onComplete, backupVoiceName!);
resolve(true);
} catch (e) {
reject(e);
}
}
console.log(e);
reject(e);
});
});
};

View File

@ -0,0 +1,242 @@
import axios from 'axios';
import { Signer } from '@volcengine/openapi';
import { pcmToWavArrayBuffer } from 'src/utils/pcmToWav';
import AliOss, { OSSRegionType } from 'src/utils/aliOss';
import { v4 as uuid } from 'uuid';
export type ReturnBaseType = {
BaseResp: {
StatusCode: number;
StatusMessage: string;
};
};
/**
*
*/
export class VolcenAudioSpeakService {
// 火山引擎base api地址
static ENGIN_BASE_URL_API = 'https://open.volcengineapi.com/';
// 训练声音
static SPEAK_CLONE_API = 'https://openspeech.bytedance.com/api/v1/mega_tts/audio/upload';
// TTS API
static TTS_API = 'https://openspeech.bytedance.com/api/v1/tts';
static TiktokAccessKeyId = 'AKLTMWNlY2Q1YzZlMTUwNDY2N2E3NTAyMmIyOWNjNjlhYWM';
static TiktokAccessToken = '6UwsXXQWnVBhYKgYjp5DoPedwU3XDecn';
static TiktokSecretKeyId = 'TlRVd05UVXlOalkzT0RFMk5EVTFORGsyWXpjNU5UZzBOak5pT0RKbU9EUQ==';
static TiktokAppId = '8167092294';
/**
* @description
* @param text
* @param speakerId ID
*/
public async textToSpeech(text: string, speakerId: string) {
const randomUUID = uuid();
const ttsBody = {
app: {
appid: VolcenAudioSpeakService.TiktokAppId,
token: 'default_token',
cluster: 'volcano_mega',
// cluster: 'volcano_mega' : 'volcano_tts',
},
user: {
uid: 'volcano_user',
},
audio: {
voice_type: speakerId,
encoding: 'pcm',
emotion: 'happy',
language: 'cn',
rate: 16000,
},
request: {
reqid: randomUUID,
text: text,
text_type: 'plain',
operation: 'query',
pure_english_opt: '1',
},
};
const volenResult = await axios.post(VolcenAudioSpeakService.TTS_API, ttsBody, {
headers: {
'Resource-Id': 'volc.megatts.tts',
Authorization: `Bearer;${VolcenAudioSpeakService.TiktokAccessToken}`,
},
});
const buffer = Buffer.from(volenResult.data.data, 'base64');
const fileName = `${speakerId}_${randomUUID}.wav`;
await AliOss('stayby-static', OSSRegionType.SH).put(
`custom-training-voice-example/${fileName}`,
Buffer.from(pcmToWavArrayBuffer(buffer)),
);
const ossUrl = `https://stayby-static.oss-cn-shanghai.aliyuncs.com/custom-training-voice-example/${fileName}`;
return {
url: ossUrl,
speakerId,
};
}
/**
* @description
* @param speakerId
* @param base64
*/
public async speakClone<T = ReturnBaseType & { speaker_id: string }>(
speakerId: string,
base64: string | Buffer,
): Promise<T> {
return new Promise(async (resolve, reject) => {
const tiktokBody = {
speaker_id: speakerId,
appid: VolcenAudioSpeakService.TiktokAppId,
audios: [
{
audio_bytes: base64,
audio_format: 'wav',
// 添加音频参数要求
// sample_rate: 16000, // 采样率必须是16kHz
// channels: 1, // 单声道
// bits: 16, // 16位深度
},
],
source: 2,
};
// 开始克隆声音,参数: { speaker_id: 'S_FC60x0Gb1', appid: '8167092294', audio_format: 'wav' }
console.log('开始克隆声音,参数:', {
speaker_id: speakerId,
appid: VolcenAudioSpeakService.TiktokAppId,
audio_format: 'wav',
});
axios
.post(VolcenAudioSpeakService.SPEAK_CLONE_API, tiktokBody, {
headers: {
'Resource-Id': 'volc.megatts.voiceclone',
Authorization: `Bearer;${VolcenAudioSpeakService.TiktokAccessToken}`,
'Content-Type': 'application/json',
},
})
.then((result) => {
// 检查业务状态码
if (result.data?.BaseResp?.StatusCode !== 0) {
console.error('克隆声音业务错误:', result.data);
throw new Error(`训练声音业务错误: ${result.data?.BaseResp?.StatusMessage || '未知错误'}`);
}
resolve(result.data as T);
})
.catch((error) => {
reject(error);
});
});
}
/**
* @description
* @param voiceId
*/
public async speakActivate(voiceId: string) {
const activatePost = {
region: 'cn-north-1',
method: 'POST',
params: {
Action: 'ActivateMegaTTSTrainStatus',
Version: '2023-11-07',
},
headers: {
'Content-Type': 'application/json',
},
body: {
AppId: VolcenAudioSpeakService.TiktokAppId,
SpeakerIDs: [voiceId],
},
};
const signer = new Signer(activatePost, 'speech_saas_prod');
signer.addAuthorization({
accessKeyId: VolcenAudioSpeakService.TiktokAccessKeyId,
secretKey: VolcenAudioSpeakService.TiktokSecretKeyId,
});
// 激活
const activateVoiceResult = await axios.post(
VolcenAudioSpeakService.ENGIN_BASE_URL_API + '?Action=ActivateMegaTTSTrainStatus&Version=2023-11-07',
activatePost.body,
{
headers: activatePost.headers,
},
);
const hasCurrentSpeakId = activateVoiceResult.data?.Result.Statuses.find((item: any) => item.SpeakerID === voiceId);
return hasCurrentSpeakId;
}
/**
* speakId
*/
public async getVoiceId() {
const query = {
region: 'cn-north-1',
method: 'POST',
params: {
Action: 'ListMegaTTSTrainStatus',
Version: '2023-11-07',
},
headers: {
'Content-Type': 'application/json; charset=utf-8',
},
body: {
AppId: VolcenAudioSpeakService.TiktokAppId,
},
};
const signer = new Signer(query, 'speech_saas_prod');
signer.addAuthorization({
accessKeyId: VolcenAudioSpeakService.TiktokAccessKeyId,
secretKey: VolcenAudioSpeakService.TiktokSecretKeyId,
});
const megaListResult = await axios.post(
VolcenAudioSpeakService.ENGIN_BASE_URL_API + `?Action=ListMegaTTSTrainStatus&Version=2023-11-07`,
query.body,
{
headers: query.headers,
},
);
// 获取未激活的音色
const unknownStatusItem = megaListResult.data.Result.Statuses.find((item: any) => item.State === 'Unknown');
return unknownStatusItem.SpeakerID;
}
/**
* @description base64
* @param ossUrl OSSURL
*/
public async getAudioBase64(ossUrl: string): Promise<string> {
try {
const response = await axios.get(ossUrl, {
responseType: 'arraybuffer',
});
// 确保音频数据符合要求16kHz采样率单声道16位深度的WAV格式
const audioBuffer = Buffer.from(response.data);
const wavBuffer = await this.convertToRequiredFormat(audioBuffer);
return wavBuffer.toString('base64');
} catch (error) {
console.error('获取音频base64失败', error);
throw new Error(`获取音频base64失败: ${error.message}`);
}
}
/**
*
*/
private async convertToRequiredFormat(audioBuffer: Buffer): Promise<Buffer> {
// 使用pcmToWavArrayBuffer进行格式转换
// 确保输出的WAV文件符合16kHz采样率单声道16位深度
const wavArrayBuffer = pcmToWavArrayBuffer(audioBuffer);
return Buffer.from(wavArrayBuffer);
}
}

View File

@ -0,0 +1,20 @@
import * as aliOss from 'ali-oss';
const defaultBucket = 'stayby-static-fast';
export enum OSSRegionType {
BJ = 'oss-cn-beijing',
SH = 'oss-cn-shanghai',
}
const AliOss = (bucket = defaultBucket, region = OSSRegionType.SH) => {
return new aliOss({
region,
bucket: bucket,
accessKeyId: 'LTAI5tEday8PJNaMTz5mp8g4',
accessKeySecret: 'ck84eTxx4aSTjornlYrCy8RkurCHfc',
secure: true,
});
};
export default AliOss;

View File

@ -0,0 +1,74 @@
// const pcmToWavArrayBufferbu = (pcmData: ArrayBuffer) => {
// // 添加44字节的wav文件头
// const pcmHeadString = "52 49 46 46 24 5C 02 00 57 41 56 45 66 6d 74 20 10 00 00 00 01 00 01 00 80 3e 00 00 00 7d 00 00 02 00 10 00 64 61 74 61 00 5C 02 00";
// const pcmHeadArray = pcmHeadString.split(" ");
// const pcmHead = new Uint8Array(pcmHeadArray.length);
// for (let i = 0; i < pcmHeadArray.length; i++) {
// pcmHead[i] = parseInt(pcmHeadArray[i]!, 16);
// }
// let wavData = new Uint8Array(pcmData.byteLength + pcmHead.byteLength);
// wavData.set(pcmHead, 0);
// wavData.set(new Uint8Array(pcmData), pcmHead.byteLength);
// return wavData.buffer;
// }
const pcmToWavArrayBuffer = (pcmData: ArrayBuffer) => {
const sampleRate = 16000;
const numChannels = 1;
const bitdepth = 16;
// const numFrames = pcmData.byteLength / (bitdepth / 8);
const wavData = new ArrayBuffer(44 + pcmData.byteLength);
const view = new DataView(wavData);
const writeString = (view: DataView, offset: number, str: string) => {
for (let i = 0; i < str.length; i++) {
view.setUint8(offset + i, str.charCodeAt(i));
}
};
const writeInt32 = (view: DataView, offset: number, value: number) => {
view.setUint32(offset, value, true);
};
const writeInt16 = (view: DataView, offset: number, value: number) => {
view.setUint16(offset, value, true);
};
/* RIFF identifier */
writeString(view, 0, 'RIFF');
/* file length */
writeInt32(view, 4, 36 + pcmData.byteLength);
/* RIFF type */
writeString(view, 8, 'WAVE');
/* format chunk identifier */
writeString(view, 12, 'fmt ');
/* format chunk length */
writeInt32(view, 16, 16);
/* sample format (raw) */
writeInt16(view, 20, 1);
/* channel count */
writeInt16(view, 22, numChannels);
/* sample rate */
writeInt32(view, 24, sampleRate);
/* byte rate (sample rate * block align) */
writeInt32(view, 28, sampleRate * 4);
/* block align (channel count * bytes per sample) */
writeInt16(view, 32, numChannels * 2);
/* bits per sample */
writeInt16(view, 34, 16);
/* data chunk identifier */
writeString(view, 36, 'data');
/* data chunk length */
writeInt32(view, 40, pcmData.byteLength);
// 将wavData转成UInt8Array然后将pcmData的内容拼接到wavData后面
const finalData = new Uint8Array(44 + pcmData.byteLength);
finalData.set(new Uint8Array(view.buffer), 0);
finalData.set(new Uint8Array(pcmData), 44);
return finalData.buffer;
};
const wavToPcmArrayBuffer = (wavData: ArrayBuffer) => {
// 把wav文件头去掉即可成为pcm文件
const pcmData = wavData.slice(44);
return pcmData;
};
export { pcmToWavArrayBuffer, wavToPcmArrayBuffer };

Binary file not shown.

Binary file not shown.

Binary file not shown.