wokada-voice-changer/client/lib/src/AudioStreamer.ts
2023-01-05 02:28:36 +09:00

277 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}