wokada-voice-changer/client/lib/src/AudioStreamer.ts

277 lines
10 KiB
TypeScript
Raw Normal View History

2023-01-05 02:28:36 +09:00
import { io, Socket } from "socket.io-client";
import { DefaultEventsMap } from "@socket.io/component-emitter";
import { Duplex, DuplexOptions } from "readable-stream";
import { DefaultVoiceChangerRequestParamas, MajarModeTypes, VoiceChangerMode, VoiceChangerRequestParamas } from "./const";
export type Callbacks = {
onVoiceReceived: (voiceChangerMode: VoiceChangerMode, data: ArrayBuffer) => void
}
export type AudioStreamerListeners = {
notifySendBufferingTime: (time: number) => void
notifyResponseTime: (time: number) => void
notifyException: (message: string) => void
}
export class AudioStreamer extends Duplex {
private callbacks: Callbacks
private audioStreamerListeners: AudioStreamerListeners
private majarMode: MajarModeTypes
private serverUrl = ""
private socket: Socket<DefaultEventsMap, DefaultEventsMap> | null = null
private voiceChangerMode: VoiceChangerMode = "realtime"
private requestParamas: VoiceChangerRequestParamas = DefaultVoiceChangerRequestParamas
private chunkNum = 8
private requestChunks: ArrayBuffer[] = []
private recordChunks: ArrayBuffer[] = []
private isRecording = false
// performance monitor
private bufferStart = 0;
constructor(majarMode: MajarModeTypes, callbacks: Callbacks, audioStreamerListeners: AudioStreamerListeners, options?: DuplexOptions) {
super(options);
this.majarMode = majarMode
this.callbacks = callbacks
this.audioStreamerListeners = audioStreamerListeners
}
private createSocketIO = () => {
if (this.socket) {
this.socket.close()
}
if (this.majarMode === "sio") {
this.socket = io(this.serverUrl);
this.socket.on('connect', () => console.log(`[SIO] sonnect to ${this.serverUrl}`));
this.socket.on('response', (response: any[]) => {
const cur = Date.now()
const responseTime = cur - response[0]
const result = response[1] as ArrayBuffer
if (result.byteLength < 128 * 2) {
this.audioStreamerListeners.notifyException(`[SIO] recevied data is too short ${result.byteLength}`)
} else {
this.audioStreamerListeners.notifyException(``)
this.callbacks.onVoiceReceived(this.voiceChangerMode, response[1])
this.audioStreamerListeners.notifyResponseTime(responseTime)
}
});
}
}
// Option Change
setServerUrl = (serverUrl: string, mode: MajarModeTypes) => {
this.serverUrl = serverUrl
this.majarMode = mode
window.open(serverUrl, '_blank')
console.log(`[AudioStreamer] Server Setting:${this.serverUrl} ${this.majarMode}`)
this.createSocketIO()// mode check is done in the method.
}
setRequestParams = (val: VoiceChangerRequestParamas) => {
this.requestParamas = val
}
setChunkNum = (num: number) => {
this.chunkNum = num
}
setVoiceChangerMode = (val: VoiceChangerMode) => {
this.voiceChangerMode = val
}
// Main Process
//// Pipe from mic stream
_write = (chunk: AudioBuffer, _encoding: any, callback: any) => {
const buffer = chunk.getChannelData(0);
// console.log("SAMPLERATE:", chunk.sampleRate, chunk.numberOfChannels, chunk.length, buffer)
if (this.voiceChangerMode === "realtime") {
this._write_realtime(buffer)
} else {
this._write_record(buffer)
}
callback();
}
private _write_realtime = (buffer: Float32Array) => {
// bufferSize個のデータ48Khzが入ってくる。
//// 48000Hz で入ってくるので間引いて24000Hzに変換する。
//// バイトサイズは周波数変換で(x1/2), 16bit(2byte)で(x2)
const arrayBuffer = new ArrayBuffer((buffer.length / 2) * 2)
const dataView = new DataView(arrayBuffer);
for (let i = 0; i < buffer.length; i++) {
if (i % 2 == 0) {
let s = Math.max(-1, Math.min(1, buffer[i]));
s = s < 0 ? s * 0x8000 : s * 0x7FFF
// 2分の1個目で2バイトずつ進むので((i/2)*2)
dataView.setInt16((i / 2) * 2, s, true);
}
}
// 256byte(最低バッファサイズ256から間引いた個数x2byte)をchunkとして管理
const chunkByteSize = 256 // (const.ts ★1)
for (let i = 0; i < arrayBuffer.byteLength / chunkByteSize; i++) {
const ab = arrayBuffer.slice(i * chunkByteSize, (i + 1) * chunkByteSize)
this.requestChunks.push(ab)
}
//// リクエストバッファの中身が、リクエスト送信数と違う場合は処理終了。
if (this.requestChunks.length < this.chunkNum) {
return
}
// リクエスト用の入れ物を作成
const windowByteLength = this.requestChunks.reduce((prev, cur) => {
return prev + cur.byteLength
}, 0)
const newBuffer = new Uint8Array(windowByteLength);
// リクエストのデータをセット
this.requestChunks.reduce((prev, cur) => {
newBuffer.set(new Uint8Array(cur), prev)
return prev + cur.byteLength
}, 0)
console.log("send buff length", newBuffer.length)
this.sendBuffer(newBuffer)
this.requestChunks = []
this.audioStreamerListeners.notifySendBufferingTime(Date.now() - this.bufferStart)
this.bufferStart = Date.now()
}
private _write_record = (buffer: Float32Array) => {
if (!this.isRecording) { return }
// buffer(for48Khz)x16bit * chunksize / 2(for24Khz)
const sendBuffer = new ArrayBuffer(buffer.length * 2 / 2);
const sendDataView = new DataView(sendBuffer);
for (var i = 0; i < buffer.length; i++) {
if (i % 2 == 0) {
let s = Math.max(-1, Math.min(1, buffer[i]));
s = s < 0 ? s * 0x8000 : s * 0x7FFF
sendDataView.setInt16(i, s, true);
// if (i % 3000 === 0) {
// console.log("buffer_converting", s, buffer[i])
// }
}
}
this.recordChunks.push(sendBuffer)
}
// Near Realtime用のトリガ
sendRecordedData = () => {
const length = this.recordChunks.reduce((prev, cur) => {
return prev + cur.byteLength
}, 0)
const newBuffer = new Uint8Array(length);
this.recordChunks.reduce((prev, cur) => {
newBuffer.set(new Uint8Array(cur), prev)
return prev + cur.byteLength
}, 0)
this.sendBuffer(newBuffer)
}
startRecord = () => {
this.recordChunks = []
this.isRecording = true
}
stopRecord = () => {
this.isRecording = false
}
private sendBuffer = async (newBuffer: Uint8Array) => {
if (this.serverUrl.length == 0) {
console.error("no server url")
throw "no server url"
}
const timestamp = Date.now()
// console.log("REQUEST_MESSAGE:", [this.gpu, this.srcId, this.dstId, timestamp, newBuffer.buffer])
console.log("SERVER_URL", this.serverUrl, this.majarMode)
const convertChunkNum = this.voiceChangerMode === "realtime" ? this.requestParamas.convertChunkNum : 0
if (this.majarMode === "sio") {
if (!this.socket) {
console.warn(`sio is not initialized`)
return
}
console.log("emit!")
this.socket.emit('request_message', [
this.requestParamas.gpu,
this.requestParamas.srcId,
this.requestParamas.dstId,
timestamp,
convertChunkNum,
this.requestParamas.crossFadeLowerValue,
this.requestParamas.crossFadeOffsetRate,
this.requestParamas.crossFadeEndRate,
newBuffer.buffer]);
} else {
const res = await postVoice(
this.serverUrl,
this.requestParamas.gpu,
this.requestParamas.srcId,
this.requestParamas.dstId,
timestamp,
convertChunkNum,
this.requestParamas.crossFadeLowerValue,
this.requestParamas.crossFadeOffsetRate,
this.requestParamas.crossFadeEndRate,
newBuffer.buffer)
if (res.byteLength < 128 * 2) {
this.audioStreamerListeners.notifyException(`[REST] recevied data is too short ${res.byteLength}`)
} else {
this.audioStreamerListeners.notifyException(``)
this.callbacks.onVoiceReceived(this.voiceChangerMode, res)
this.audioStreamerListeners.notifyResponseTime(Date.now() - timestamp)
}
}
}
}
export const postVoice = async (
url: string,
gpu: number,
srcId: number,
dstId: number,
timestamp: number,
convertSize: number,
crossFadeLowerValue: number,
crossFadeOffsetRate: number,
crossFadeEndRate: number,
buffer: ArrayBuffer) => {
const obj = {
gpu,
srcId,
dstId,
timestamp,
convertSize,
crossFadeLowerValue,
crossFadeOffsetRate,
crossFadeEndRate,
buffer: Buffer.from(buffer).toString('base64')
};
const body = JSON.stringify(obj);
const res = await fetch(`${url}`, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: body
})
const receivedJson = await res.json()
const changedVoiceBase64 = receivedJson["changedVoiceBase64"]
const buf = Buffer.from(changedVoiceBase64, "base64")
const ab = new ArrayBuffer(buf.length);
// console.log("RECIV", buf.length)
const view = new Uint8Array(ab);
for (let i = 0; i < buf.length; ++i) {
view[i] = buf[i];
}
return ab
}