diff --git a/src/webpage/audio.ts b/src/webpage/audio.ts index a92bbaf..9c096d2 100644 --- a/src/webpage/audio.ts +++ b/src/webpage/audio.ts @@ -1,6 +1,6 @@ import{ getBulkInfo }from"./login.js"; -class Voice{ +class AVoice{ audioCtx: AudioContext; info: { wave: string | Function; freq: number }; playing: boolean; @@ -95,7 +95,7 @@ class Voice{ static noises(noise: string): void{ switch(noise){ case"three": { - const voicy = new Voice("sin", 800); + const voicy = new AVoice("sin", 800); voicy.play(); setTimeout(_=>{ voicy.freq = 1000; @@ -109,7 +109,7 @@ class Voice{ break; } case"zip": { - const voicy = new Voice((t: number, freq: number)=>{ + const voicy = new AVoice((t: number, freq: number)=>{ return Math.sin((t + 2) ** Math.cos(t * 4) * Math.PI * 2 * freq); }, 700); voicy.play(); @@ -119,7 +119,7 @@ class Voice{ break; } case"square": { - const voicy = new Voice("square", 600, 0.4); + const voicy = new AVoice("square", 600, 0.4); voicy.play(); setTimeout(_=>{ voicy.freq = 800; @@ -133,7 +133,7 @@ class Voice{ break; } case"beep": { - const voicy = new Voice("sin", 800); + const voicy = new AVoice("sin", 800); voicy.play(); setTimeout(_=>{ voicy.stop(); @@ -161,4 +161,4 @@ class Voice{ return userinfos.preferences.notisound; } } -export{ Voice }; +export{ AVoice as AVoice }; diff --git a/src/webpage/channel.ts b/src/webpage/channel.ts index f792ab7..3d23cb9 100644 --- a/src/webpage/channel.ts +++ b/src/webpage/channel.ts @@ -1,6 +1,6 @@ "use strict"; import{ Message }from"./message.js"; -import{ Voice }from"./audio.js"; +import{ AVoice }from"./audio.js"; import{ Contextmenu }from"./contextmenu.js"; import{ Dialog }from"./dialog.js"; import{ Guild }from"./guild.js"; @@ -10,16 +10,10 @@ import{ Settings }from"./settings.js"; import{ Role, RoleList }from"./role.js"; import{ InfiniteScroller }from"./infiniteScroller.js"; import{ SnowFlake }from"./snowflake.js"; -import{ - channeljson, - embedjson, - messageCreateJson, - messagejson, - readyjson, - startTypingjson, -}from"./jsontypes.js"; +import{channeljson,embedjson,messageCreateJson,messagejson,readyjson,startTypingjson}from"./jsontypes.js"; import{ MarkDown }from"./markdown.js"; import{ Member }from"./member.js"; +import { Voice } from "./voice.js"; declare global { interface NotificationOptions { @@ -55,6 +49,7 @@ class Channel extends SnowFlake{ idToPrev: Map = new Map(); idToNext: Map = new Map(); messages: Map = new Map(); + voice?:Voice; static setupcontextmenu(){ this.contextmenu.addbutton("Copy channel id", function(this: Channel){ navigator.clipboard.writeText(this.id); @@ -336,6 +331,9 @@ class Channel extends SnowFlake{ } this.setUpInfiniteScroller(); this.perminfo ??= {}; + if(this.type===2){ + this.voice=new Voice(this); + } } get perminfo(){ return this.guild.perminfo.channels[this.id]; @@ -840,6 +838,9 @@ class Channel extends SnowFlake{ loading.classList.add("loading"); this.rendertyping(); this.localuser.getSidePannel(); + if(this.voice){ + this.voice.join(); + } await this.putmessages(); await prom; if(id !== Channel.genid){ @@ -1334,7 +1335,7 @@ class Channel extends SnowFlake{ ); } notify(message: Message, deep = 0){ - Voice.noises(Voice.getNotificationSound()); + AVoice.noises(AVoice.getNotificationSound()); if(!("Notification" in window)){ }else if(Notification.permission === "granted"){ let noticontent: string | undefined | null = message.content.textContent; diff --git a/src/webpage/jsontypes.ts b/src/webpage/jsontypes.ts index 98a38f2..11d08a2 100644 --- a/src/webpage/jsontypes.ts +++ b/src/webpage/jsontypes.ts @@ -408,79 +408,109 @@ type wsjson = | "MESSAGE_REACTION_REMOVE_EMOJI"; } | { -op: 0; -t: "GUILD_MEMBERS_CHUNK"; -d: memberChunk; -s: number; + op: 0; + t: "GUILD_MEMBERS_CHUNK"; + d: memberChunk; + s: number; } | { -op: 0; -d: { -id: string; -guild_id?: string; -channel_id: string; -}; -s: number; -t: "MESSAGE_DELETE"; + op: 0; + d: { + id: string; + guild_id?: string; + channel_id: string; + }; + s: number; + t: "MESSAGE_DELETE"; } | { -op: 0; -d: { -guild_id?: string; -channel_id: string; -} & messagejson; -s: number; -t: "MESSAGE_UPDATE"; + op: 0; + d: { + guild_id?: string; + channel_id: string; + } & messagejson; + s: number; + t: "MESSAGE_UPDATE"; } | messageCreateJson | readyjson | { -op: 11; -s: undefined; -d: {}; + op: 11; + s: undefined; + d: {}; } | { -op: 10; -s: undefined; -d: { -heartbeat_interval: number; -}; + op: 10; + s: undefined; + d: { + heartbeat_interval: number; + }; } | { -op: 0; -t: "MESSAGE_REACTION_ADD"; -d: { -user_id: string; -channel_id: string; -message_id: string; -guild_id?: string; -emoji: emojijson; -member?: memberjson; -}; -s: number; + op: 0; + t: "MESSAGE_REACTION_ADD"; + d: { + user_id: string; + channel_id: string; + message_id: string; + guild_id?: string; + emoji: emojijson; + member?: memberjson; + }; + s: number; } | { -op: 0; -t: "MESSAGE_REACTION_REMOVE"; -d: { -user_id: string; -channel_id: string; -message_id: string; -guild_id: string; -emoji: emojijson; -}; -s: 3; -}|memberlistupdatejson; -type memberChunk = { -guild_id: string; -nonce: string; -members: memberjson[]; -presences: presencejson[]; -chunk_index: number; -chunk_count: number; -not_found: string[]; -}; + op: 0; + t: "MESSAGE_REACTION_REMOVE"; + d: { + user_id: string; + channel_id: string; + message_id: string; + guild_id: string; + emoji: emojijson; + }; + s: 3; +}|memberlistupdatejson|voiceupdate|voiceserverupdate; + +type memberChunk = { + guild_id: string; + nonce: string; + members: memberjson[]; + presences: presencejson[]; + chunk_index: number; + chunk_count: number; + not_found: string[]; +}; +type voiceupdate={ + op: 0, + t: "VOICE_STATE_UPDATE", + d: { + guild_id: string, + channel_id: string, + user_id: string, + member: memberjson, + session_id: string, + token: string, + deaf: boolean, + mute: boolean, + self_deaf: boolean, + self_mute: boolean, + self_video: boolean, + suppress: boolean + }, + s: number +}; +type voiceserverupdate={ + op: 0, + t: "VOICE_SERVER_UPDATE", + d: { + token: string, + guild_id: string, + endpoint: string + }, + s: 6 +}; type memberlistupdatejson={ op: 0, s: number, @@ -513,6 +543,65 @@ type memberlistupdatejson={ }[] } } +type webRTCSocket= { + op: 8, + d: { + heartbeat_interval: number + } +}|{ + op:6, + d:{t: number} +}|{ + op: 2, + d: { + ssrc: number, + "streams": { + type: "video",//probally more options, but idk + rid: string, + quality: number, + ssrc: number, + rtx_ssrc:number + }[], + ip: number, + port: number, + "modes": [],//no clue + "experiments": []//no clue + } +}|sdpback|opRTC12; +type sdpback={ + op: 4, + d: { + audioCodec: string, + videoCodec: string, + media_session_id: string, + sdp: string + } +}; +type opRTC12={ + op: 12, + d: { + user_id: string, + audio_ssrc: number, + video_ssrc: number, + streams: [ + { + type: "video", + rid: "100", + ssrc: number, + active: boolean, + quality: 100, + rtx_ssrc: number, + max_bitrate: 2500000, + max_framerate: number, + max_resolution: { + type: "fixed", + width: number, + height: number + } + } + ] + } +} export{ readyjson, dirrectjson, @@ -532,5 +621,10 @@ export{ messageCreateJson, memberChunk, invitejson, - memberlistupdatejson + memberlistupdatejson, + voiceupdate, + voiceserverupdate, + webRTCSocket, + sdpback, + opRTC12 }; diff --git a/src/webpage/localuser.ts b/src/webpage/localuser.ts index b96d8e7..67c704d 100644 --- a/src/webpage/localuser.ts +++ b/src/webpage/localuser.ts @@ -1,27 +1,17 @@ import{ Guild }from"./guild.js"; import{ Channel }from"./channel.js"; import{ Direct }from"./direct.js"; -import{ Voice }from"./audio.js"; +import{ AVoice }from"./audio.js"; import{ User }from"./user.js"; import{ Dialog }from"./dialog.js"; import{ getapiurls, getBulkInfo, setTheme, Specialuser }from"./login.js"; -import{ - channeljson, - guildjson, - mainuserjson, - memberjson, - memberlistupdatejson, - messageCreateJson, - presencejson, - readyjson, - startTypingjson, - wsjson, -}from"./jsontypes.js"; +import{channeljson,guildjson,mainuserjson,memberjson,memberlistupdatejson,messageCreateJson,presencejson,readyjson,startTypingjson,voiceupdate,wsjson,}from"./jsontypes.js"; import{ Member }from"./member.js"; import{ Form, FormError, Options, Settings }from"./settings.js"; import{ MarkDown }from"./markdown.js"; import { Bot } from "./bot.js"; import { Role } from "./role.js"; +import { Voice } from "./voice.js"; const wsCodesRetry = new Set([4000, 4003, 4005, 4007, 4008, 4009]); @@ -486,8 +476,21 @@ class Localuser{ this.memberListUpdate(temp) break; } + case "VOICE_STATE_UPDATE": + if(this.waitingForVoice){ + this.waitingForVoice(temp); + } + + break; + case "VOICE_SERVER_UPDATE": + if(this.currentVoice){ + Voice.url=temp.d.endpoint; + Voice.gotUrl(); + } + break; } + }else if(temp.op === 10){ if(!this.ws)return; console.log("heartbeat down"); @@ -501,6 +504,29 @@ class Localuser{ }, this.heartbeat_interval); } } + waitingForVoice?:((arg:voiceupdate|undefined)=>void); + currentVoice?:Voice; + async joinVoice(voice:Voice){ + this.currentVoice=voice; + if(this.ws){ + this.ws.send(JSON.stringify({ + d:{ + guild_id: voice.guild.id, + channel_id: voice.channel.id, + self_mute: true,//todo + self_deaf: false,//todo + self_video: false,//What is this? I have some guesses + flags: 2//????? + }, + op:4 + })); + if(this.waitingForVoice){ + this.waitingForVoice(undefined); + } + return await new Promise((res)=>{this.waitingForVoice=res;}) + } + return undefined; + } heartbeat_interval: number = 0; updateChannel(json: channeljson): void{ const guild = this.guildids.get(json.guild_id); @@ -1167,18 +1193,18 @@ class Localuser{ ); } { - const sounds = Voice.sounds; + const sounds = AVoice.sounds; tas .addSelect( "Notification sound:", _=>{ - Voice.setNotificationSound(sounds[_]); + AVoice.setNotificationSound(sounds[_]); }, sounds, - { defaultIndex: sounds.indexOf(Voice.getNotificationSound()) } + { defaultIndex: sounds.indexOf(AVoice.getNotificationSound()) } ) .watchForChange(_=>{ - Voice.noises(sounds[_]); + AVoice.noises(sounds[_]); }); } diff --git a/src/webpage/login.ts b/src/webpage/login.ts index 9dc4ed0..3de326c 100644 --- a/src/webpage/login.ts +++ b/src/webpage/login.ts @@ -116,12 +116,12 @@ function setDefaults(){ setDefaults(); class Specialuser{ serverurls: { -api: string; -cdn: string; -gateway: string; -wellknown: string; -login: string; -}; + api: string; + cdn: string; + gateway: string; + wellknown: string; + login: string; + }; email: string; token: string; loggedin; diff --git a/src/webpage/voice.ts b/src/webpage/voice.ts new file mode 100644 index 0000000..0b7aa79 --- /dev/null +++ b/src/webpage/voice.ts @@ -0,0 +1,403 @@ +import { Channel } from "./channel.js"; +import { sdpback, webRTCSocket } from "./jsontypes.js"; + +class Voice{ + owner:Channel; + static url?:string; + static gotUrl:()=>void; + static geturl=new Promise(res=>{this.gotUrl=res}) + get channel(){ + return this.owner; + } + get guild(){ + return this.owner.owner; + } + get localuser(){ + return this.owner.localuser; + } + get info(){ + return this.owner.info; + } + constructor(owner:Channel){ + this.owner=owner; + } + pc?:RTCPeerConnection; + ws?:WebSocket; + timeout:number=30000; + interval:NodeJS.Timeout=0 as unknown as NodeJS.Timeout; + time:number=0; + seq:number=0; + sendAlive(){ + if(this.ws){ + this.ws.send(JSON.stringify({ op: 3,d:10})); + } + } + readonly users= new Map(); + packet(message:MessageEvent){ + const data=message.data + if(typeof data === "string"){ + const json:webRTCSocket = JSON.parse(data); + switch(json.op){ + case 2: + this.startWebRTC(); + break; + case 4: + this.continueWebRTC(json); + break; + case 6: + this.time=json.d.t; + setTimeout(this.sendAlive.bind(this), this.timeout); + break; + case 8: + this.timeout=json.d.heartbeat_interval; + setTimeout(this.sendAlive.bind(this), 1000); + break; + case 12: + this.users.set(json.d.audio_ssrc,json.d.user_id); + break + + + } + } + } + offer?:string; + cleanServerSDP(sdp:string,bundle1:string,bundle2:string):string{ + if(!this.offer) throw new Error("Offer is missing :P"); + let cline:string|undefined; + console.log(sdp); + for(const line of sdp.split("\n")){ + if(line.startsWith("c=")){ + cline=line; + break; + } + } + if(!cline) throw new Error("c line wasn't found"); + const parsed1=Voice.parsesdp(sdp).medias[0]; + //const parsed2=Voice.parsesdp(this.offer); + const rtcport=(parsed1.atr.get("rtcp") as Set).values().next().value as string; + const ICE_UFRAG=(parsed1.atr.get("ice-ufrag") as Set).values().next().value as string; + const ICE_PWD=(parsed1.atr.get("ice-pwd") as Set).values().next().value as string; + const FINGERPRINT=(parsed1.atr.get("fingerprint") as Set).values().next().value as string; + const candidate=(parsed1.atr.get("candidate") as Set).values().next().value as string; + let build=`v=0\r +o=- 1420070400000 0 IN IP4 127.0.0.1\r +s=-\r +t=0 0\r +a=msid-semantic: WMS *\r +a=group:BUNDLE ${bundle1} ${bundle2}\r +m=audio ${parsed1.port} UDP/TLS/RTP/SAVPF 111\r +${cline}\r +a=rtpmap:111 opus/48000/2\r +a=fmtp:111 minptime=10;useinbandfec=1;usedtx=1\r +a=rtcp:${rtcport}\r +a=rtcp-fb:111 transport-cc\r +a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r +a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01/r/n +a=setup:passive\r +a=mid:${bundle1}\r +a=maxptime:60\r +a=inactive\r +a=ice-ufrag:${ICE_UFRAG}\r +a=ice-pwd:${ICE_PWD}\r +a=fingerprint:${FINGERPRINT}\r +a=candidate:${candidate}\r +a=rtcp-mux\r +m=video ${rtcport} UDP/TLS/RTP/SAVPF 102 103\r +${cline}\r +a=rtpmap:102 H264/90000\r +a=rtpmap:103 rtx/90000\r +a=fmtp:102 x-google-max-bitrate=2500;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r +a=fmtp:103 apt=102\r +a=rtcp:${rtcport}\r +a=rtcp-fb:102 ccm fir\r +a=rtcp-fb:102 nack\r +a=rtcp-fb:102 nack pli\r +a=rtcp-fb:102 goog-remb\r +a=rtcp-fb:102 transport-cc\r +a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time/r/n +a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01/r/n +a=extmap:14 urn:ietf:params:rtp-hdrext:toffset\r +a=extmap:13 urn:3gpp:video-orientation\r +a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay/r/na=setup:passive/r/n +a=mid:${bundle2}\r +a=inactive\r +a=ice-ufrag:${ICE_UFRAG}\r +a=ice-pwd:${ICE_PWD}\r +a=fingerprint:${FINGERPRINT}\r +a=candidate:${candidate}\r +a=rtcp-mux\r +`; + + return build; + } + counter?:string; + negotationneeded(){ + if(this.pc&&this.offer){ + const pc=this.pc; + pc.addEventListener("negotiationneeded", async ()=>{ + this.offer=(await pc.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true + })).sdp; + await pc.setLocalDescription({sdp:this.offer}); + const ld=pc.localDescription; + if(!ld) throw new Error("localDescription isn't defined"); + if(!this.counter) throw new Error("localDescription isn't defined"); + const counter=this.counter; + const parsed = Voice.parsesdp(ld.sdp); + const group=parsed.atr.get("group"); + if(!group) throw new Error("group isn't in sdp"); + const groupings=(group.entries().next().value as [string, string])[0].split(" ") as [string,string,string]; + groupings[2]=groupings[2].replace("\r",""); + console.log(groupings); + const remote:{sdp:string,type:RTCSdpType}={sdp:this.cleanServerSDP(counter,groupings[1],groupings[2]),type:"answer"}; + console.log(remote); + await pc.setRemoteDescription(remote); + const senders=this.senders.difference(this.ssrcMap); + for(const sender of senders){ + for(const thing of (await sender.getStats())){ + console.log(thing[1]); + if(thing[1].ssrc){ + this.ssrcMap.set(sender,thing[1].ssrc); + this.makeOp12(sender) + } + } + } + console.log(this.ssrcMap); + }); + } + } + async makeOp12(sender:RTCRtpSender){ + if(this.ws){ + this.ws.send(JSON.stringify({ + op: 12, + d: { + audio_ssrc: this.ssrcMap.get(sender), + video_ssrc: 0, + rtx_ssrc: 0, + streams: [ + { + type: "video", + rid: "100", + ssrc: 0,//TODO + active: false, + quality: 100, + rtx_ssrc: 0,//TODO + max_bitrate: 2500000,//TODO + max_framerate: 0,//TODO + max_resolution: { + type: "fixed", + width: 0,//TODO + height: 0//TODO + } + } + ] + } + })) + } + } + senders:Set=new Set(); + ssrcMap:Map=new Map(); + async continueWebRTC(data:sdpback){ + if(this.pc&&this.offer){ + const pc=this.pc; + this.negotationneeded(); + const audioStream = await navigator.mediaDevices.getUserMedia({video: false, audio: true} ); + for (const track of audioStream.getTracks()) + { + //Add track + const sender = pc.addTrack(track,audioStream); + this.senders.add(sender); + } + this.counter=data.d.sdp; + pc.ontrack = ({ streams: [stream] }) => console.log("got audio stream", stream); + + } + } + async startWebRTC(){ + const pc = new RTCPeerConnection(); + this.pc=pc; + const offer = await pc.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true + }); + const sdp=offer.sdp; + this.offer=sdp; + + if(!sdp){this.ws?.close();return;} + const parsed=Voice.parsesdp(sdp); + const video=new Map(); + const audio=new Map(); + let cur:[number,number]|undefined; + let i=0; + for(const thing of parsed.medias){ + try{ + if(thing.media==="video"){ + const rtpmap=thing.atr.get("rtpmap"); + if(!rtpmap) continue; + for(const codecpair of rtpmap){ + + const [port, codec]=codecpair.split(" "); + if(cur&&codec.split("/")[0]==="rtx"){ + cur[1]=Number(port); + cur=undefined; + continue + } + if(video.has(codec.split("/")[0])) continue; + cur=[Number(port),-1]; + video.set(codec.split("/")[0],cur); + } + }else if(thing.media==="audio"){ + const rtpmap=thing.atr.get("rtpmap"); + if(!rtpmap) continue; + for(const codecpair of rtpmap){ + const [port, codec]=codecpair.split(" "); + if(audio.has(codec.split("/")[0])) { continue}; + audio.set(codec.split("/")[0],Number(port)); + } + } + }finally{ + i++; + } + } + + const codecs:{ + name: string, + type: "video"|"audio", + priority: number, + payload_type: number, + rtx_payload_type: number|null + }[]=[]; + const include=new Set(); + const audioAlloweds=new Map([["opus",{priority:1000,}]]); + for(const thing of audio){ + if(audioAlloweds.has(thing[0])){ + include.add(thing[0]); + codecs.push({ + name:thing[0], + type:"audio", + priority:audioAlloweds.get(thing[0])?.priority as number, + payload_type:thing[1], + rtx_payload_type:null + }); + } + } + const videoAlloweds=new Map([["H264",{priority:1000}],["VP8",{priority:2000}],["VP9",{priority:3000}]]); + for(const thing of video){ + if(videoAlloweds.has(thing[0])){ + include.add(thing[0]); + codecs.push({ + name:thing[0], + type:"video", + priority:videoAlloweds.get(thing[0])?.priority as number, + payload_type:thing[1][0], + rtx_payload_type:thing[1][1] + }); + } + } + let sendsdp="a=extmap-allow-mixed"; + let first=true; + for(const media of parsed.medias){ + + for(const thing of first?["ice-ufrag","ice-pwd","ice-options","fingerprint","extmap","rtpmap"]:["extmap","rtpmap"]){ + const thing2=media.atr.get(thing); + if(!thing2) continue; + for(const thing3 of thing2){ + if(thing === "rtpmap"){ + const name=thing3.split(" ")[1].split("/")[0]; + if(include.has(name)){ + include.delete(name); + }else{ + continue; + } + } + sendsdp+=`\na=${thing}:${thing3}`; + } + } + first=false; + } + if(this.ws){ + this.ws.send(JSON.stringify({ + d:{ + codecs, + protocol:"webrtc", + data:sendsdp, + sdp:sendsdp + }, + op:1 + })); + } + } + static parsesdp(sdp:string){ + let currentA=new Map>(); + const out:{version?:number,medias:{media:string,port:number,proto:string,ports:number[],atr:Map>}[],atr:Map>}={medias:[],atr:currentA}; + for(const line of sdp.split("\n")){ + const [code,setinfo]=line.split("="); + switch(code){ + case "v": + out.version=Number(setinfo); + break; + case "o": + case "s": + case "t": + break; + case "m": + currentA=new Map(); + const [media,port,proto,...ports]=setinfo.split(" "); + const portnums=ports.map(Number); + out.medias.push({media,port:Number(port),proto,ports:portnums,atr:currentA}); + break; + case "a": + const [key, ...value] = setinfo.split(":"); + if(!currentA.has(key)){ + currentA.set(key,new Set()); + } + currentA.get(key)?.add(value.join(":")); + break; + } + } + return out; + } + async join(){ + console.warn("Joining"); + const json = await this.localuser.joinVoice(this); + if(!json) return; + if(!Voice.url){ + await Voice.geturl; + } + if(this.localuser.currentVoice!==this){return} + const ws=new WebSocket("ws://"+Voice.url as string); + this.ws=ws; + ws.addEventListener("message",(m)=>{ + this.packet(m); + }) + await new Promise(res=>{ + ws.addEventListener("open",()=>{ + res() + }) + }); + ws.send(JSON.stringify({ + "op": 0, + "d": { + server_id: this.guild.id, + user_id: json.d.user_id, + session_id: json.d.session_id, + token: json.d.token, + video: false, + "streams": [ + { + type: "video", + rid: "100", + quality: 100 + } + ] + } + })); + /* + const pc=new RTCPeerConnection(); + this.pc=pc; + //pc.setRemoteDescription({sdp:json.d.token,type:""}) + */ + } +} +export {Voice};