@openvertice/bot-sdk 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/dist/src/cliente.d.ts.map +1 -1
- package/dist/src/cliente.js +15 -5
- package/dist/src/cliente.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/tipos.d.ts +11 -0
- package/dist/src/tipos.d.ts.map +1 -1
- package/package.json +21 -5
- package/src/audio.ts +88 -0
- package/src/cliente.ts +484 -0
- package/src/gateway.ts +479 -0
- package/src/http.ts +249 -0
- package/src/index.ts +42 -0
- package/src/tipos.ts +232 -0
- package/src/voz-wrtc.ts +100 -0
- package/src/voz.ts +407 -0
- package/src/wav.ts +71 -0
- package/src/wrtc.d.ts +4 -0
package/src/voz.ts
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
// The bot's voice: it is one more peer in the mesh, not a privileged listener.
|
|
2
|
+
//
|
|
3
|
+
// The layout contract is the same the clients use
|
|
4
|
+
// (client/src/lib/webrtc/pares.ts):
|
|
5
|
+
// audio[0] = microphone · audio[1] = broadcast sound · video[0] = screen
|
|
6
|
+
// The offerer creates all three transceivers; the answerer reuses the ones from
|
|
7
|
+
// the received SDP and forces `sendrecv`. In a crossed offer, the larger id
|
|
8
|
+
// ignores the smaller one's offer.
|
|
9
|
+
import type { Bot } from "./cliente.js";
|
|
10
|
+
import type { ServidorIce, SinalDeVoz } from "./tipos.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The minimum voice needs from WebRTC — swappable in tests.
|
|
14
|
+
*
|
|
15
|
+
* `criarTransporteWrtc()` provides the Node implementation.
|
|
16
|
+
*/
|
|
17
|
+
export interface TransporteDeVoz {
|
|
18
|
+
/** Opens one peer connection, configured with the given ICE servers. */
|
|
19
|
+
criarConexao(opcoes: { iceServers: ServidorIce[] }): ConexaoDeVoz;
|
|
20
|
+
/** Creates the PCM source that feeds the published track. */
|
|
21
|
+
criarFonteDeAudio(): FonteDeAudio;
|
|
22
|
+
/** Closes every connection this transport opened. */
|
|
23
|
+
fechar(): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The slice of `RTCPeerConnection` the mesh actually uses. */
|
|
27
|
+
export interface ConexaoDeVoz {
|
|
28
|
+
/** Standard `signalingState` (`stable`, `have-local-offer`, …). */
|
|
29
|
+
signalingState: string;
|
|
30
|
+
/** Local session description, once one has been set. */
|
|
31
|
+
localDescription: { type: string; sdp?: string } | null;
|
|
32
|
+
/** Adds an `audio` or `video` m-line and returns the transceiver. */
|
|
33
|
+
addTransceiver(tipo: "audio" | "video", opcoes?: { direction?: string }): unknown;
|
|
34
|
+
/** Current transceivers, in SDP order. */
|
|
35
|
+
getTransceivers(): {
|
|
36
|
+
direction: string;
|
|
37
|
+
sender: { track: unknown; replaceTrack(track: unknown): Promise<void> };
|
|
38
|
+
}[];
|
|
39
|
+
/** Builds an offer from the current transceivers. */
|
|
40
|
+
createOffer(): Promise<{ type: "offer"; sdp: string }>;
|
|
41
|
+
/** Builds an answer to the current remote offer. */
|
|
42
|
+
createAnswer(): Promise<{ type: "answer"; sdp: string }>;
|
|
43
|
+
/** Applies a local session description. */
|
|
44
|
+
setLocalDescription(descricao: { type: string; sdp?: string }): Promise<void>;
|
|
45
|
+
/** Applies the remote session description. */
|
|
46
|
+
setRemoteDescription(descricao: { type: string; sdp?: string }): Promise<void>;
|
|
47
|
+
/** Adds a remote ICE candidate. */
|
|
48
|
+
addIceCandidate(candidato: unknown): Promise<void>;
|
|
49
|
+
/** Closes the connection and releases its resources. */
|
|
50
|
+
close(): void;
|
|
51
|
+
/** Emits local ICE candidates as they are gathered. */
|
|
52
|
+
onicecandidate: ((evento: { candidate: unknown }) => void) | null;
|
|
53
|
+
/** Emits remote tracks as they arrive. */
|
|
54
|
+
ontrack: ((evento: { track: unknown }) => void) | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Audio source: where the track the bot publishes comes from. */
|
|
58
|
+
export interface FonteDeAudio {
|
|
59
|
+
/** Creates the track fed by this source. */
|
|
60
|
+
createTrack(): unknown;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What {@link Voz} needs to run. */
|
|
64
|
+
export interface OpcoesDeVoz {
|
|
65
|
+
/** Runtime that creates peer connections and audio sources. */
|
|
66
|
+
transporte: TransporteDeVoz;
|
|
67
|
+
/**
|
|
68
|
+
* The track the bot publishes. Without it, voice creates one from
|
|
69
|
+
* `transporte.criarFonteDeAudio()` — and you feed that source (see audio.ts).
|
|
70
|
+
*/
|
|
71
|
+
track?: unknown;
|
|
72
|
+
/** What to do with the audio that arrives from the other peers. */
|
|
73
|
+
aoReceberTrack?: (peerId: string, track: unknown) => void;
|
|
74
|
+
/** Diagnostic sink. */
|
|
75
|
+
log?: (mensagem: string) => void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Result of a successful {@link Voz.entrar}. */
|
|
79
|
+
export interface EntradaNaVoz {
|
|
80
|
+
/** Channel code the bot joined. */
|
|
81
|
+
code: string;
|
|
82
|
+
/** Channel name. */
|
|
83
|
+
nome: string;
|
|
84
|
+
/** The bot's id in the room (it settles the crossed offer). */
|
|
85
|
+
eu: string;
|
|
86
|
+
/** Peers that were already in the room. */
|
|
87
|
+
peers: { id: string; nome: string; bot?: boolean }[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How long {@link Voz.entrar} waits for the server's `voice.joined`. */
|
|
91
|
+
const ESPERA_DA_ENTRADA_MS = 15_000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Voice client for a {@link Bot}.
|
|
95
|
+
*
|
|
96
|
+
* The bot is one more peer in the mesh: it offers to peers that are already in
|
|
97
|
+
* the room, answers offers, exchanges ICE candidates and publishes one audio
|
|
98
|
+
* track. Create it once, before `bot.start()`, so the listeners are in place
|
|
99
|
+
* when `voice.joined` arrives.
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```ts
|
|
103
|
+
* const transporte = await criarTransporteWrtc();
|
|
104
|
+
* const voz = new Voz(bot, { transporte });
|
|
105
|
+
* await bot.start();
|
|
106
|
+
* await voz.entrar("PATIO");
|
|
107
|
+
* ```
|
|
108
|
+
*/
|
|
109
|
+
export class Voz {
|
|
110
|
+
readonly #bot: Bot;
|
|
111
|
+
readonly #opcoes: OpcoesDeVoz;
|
|
112
|
+
readonly #conexoes = new Map<string, ConexaoDeVoz>();
|
|
113
|
+
readonly #log: (mensagem: string) => void;
|
|
114
|
+
#eu: string | null = null;
|
|
115
|
+
#canal: string | null = null;
|
|
116
|
+
#track: unknown = null;
|
|
117
|
+
#fonte: FonteDeAudio | null = null;
|
|
118
|
+
#pendente: {
|
|
119
|
+
resolver: (entrada: EntradaNaVoz) => void;
|
|
120
|
+
rejeitar: (erro: Error) => void;
|
|
121
|
+
prazo: ReturnType<typeof setTimeout>;
|
|
122
|
+
} | null = null;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Wires the voice client to a bot.
|
|
126
|
+
*
|
|
127
|
+
* It subscribes to the voice events right away, so the instance must exist
|
|
128
|
+
* before the bot starts.
|
|
129
|
+
*
|
|
130
|
+
* @param bot Bot whose gateway carries the signaling.
|
|
131
|
+
* @param opcoes Transport, optional track and logging.
|
|
132
|
+
*/
|
|
133
|
+
constructor(bot: Bot, opcoes: OpcoesDeVoz) {
|
|
134
|
+
this.#bot = bot;
|
|
135
|
+
this.#opcoes = opcoes;
|
|
136
|
+
this.#log = opcoes.log ?? (() => {});
|
|
137
|
+
this.#track = opcoes.track ?? null;
|
|
138
|
+
|
|
139
|
+
bot.on("voice.joined", (entrada) => {
|
|
140
|
+
this.#eu = entrada.eu;
|
|
141
|
+
this.#canal = entrada.code;
|
|
142
|
+
this.#log(`voz: em ${entrada.code} com ${entrada.peers.length} par(es)`);
|
|
143
|
+
this.#concluirEntrada(entrada);
|
|
144
|
+
for (const peer of entrada.peers) void this.#oferecer(peer.id);
|
|
145
|
+
});
|
|
146
|
+
bot.on("voice.left", ({ code, motivo }) => {
|
|
147
|
+
this.#log(`voz: saiu de ${code} (${motivo})`);
|
|
148
|
+
this.#canal = null;
|
|
149
|
+
this.#fecharTodas();
|
|
150
|
+
});
|
|
151
|
+
bot.on("voice.peer.joined", ({ id, nome }) => {
|
|
152
|
+
this.#log(`voz: ${nome} entrou`);
|
|
153
|
+
void this.#oferecer(id);
|
|
154
|
+
});
|
|
155
|
+
bot.on("voice.peer.left", ({ id }) => {
|
|
156
|
+
this.#conexoes.get(id)?.close();
|
|
157
|
+
this.#conexoes.delete(id);
|
|
158
|
+
});
|
|
159
|
+
bot.on("voice.signal", ({ de, signal }) => {
|
|
160
|
+
void this.#aoSinal(de, signal);
|
|
161
|
+
});
|
|
162
|
+
bot.on("error", ({ codigo, mensagem }) => {
|
|
163
|
+
if (codigo === "canal" || codigo === "voz") this.#falharEntrada(mensagem);
|
|
164
|
+
});
|
|
165
|
+
// Reconnecting takes a new seat on the server: the previous room is released
|
|
166
|
+
// in the gateway's `aoFechar` and `voice.left` never arrives (the socket
|
|
167
|
+
// died). Without releasing the room here, the bot would believe it is
|
|
168
|
+
// somewhere it is not, and `entrar` would answer "already in …" forever.
|
|
169
|
+
bot.aoMudarEstado((estado) => {
|
|
170
|
+
if (estado !== "reconectando" && estado !== "encerrado" && estado !== "recusado")
|
|
171
|
+
return;
|
|
172
|
+
this.#canal = null;
|
|
173
|
+
this.#fecharTodas();
|
|
174
|
+
this.#falharEntrada("A conexão com o gateway caiu: a voz foi encerrada.");
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Channel the bot is in (`null` = not in voice). */
|
|
179
|
+
get canal(): string | null {
|
|
180
|
+
return this.#canal;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The published track; created on demand when it was not passed in the options. */
|
|
184
|
+
get track(): unknown {
|
|
185
|
+
if (!this.#track) {
|
|
186
|
+
this.#fonte ??= this.#opcoes.transporte.criarFonteDeAudio();
|
|
187
|
+
this.#track = this.#fonte.createTrack();
|
|
188
|
+
}
|
|
189
|
+
return this.#track;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Joins a voice channel and offers to everyone already there.
|
|
194
|
+
*
|
|
195
|
+
* @param code Channel code (case-insensitive).
|
|
196
|
+
* @returns The room information, once the server confirms the join.
|
|
197
|
+
* @throws {Error} When the server rejects the join or does not confirm it
|
|
198
|
+
* within {@link ESPERA_DA_ENTRADA_MS}.
|
|
199
|
+
*/
|
|
200
|
+
async entrar(code: string): Promise<EntradaNaVoz> {
|
|
201
|
+
const promessa = new Promise<EntradaNaVoz>((resolver, rejeitar) => {
|
|
202
|
+
const prazo = setTimeout(() => {
|
|
203
|
+
this.#pendente = null;
|
|
204
|
+
rejeitar(new Error("O servidor não confirmou a entrada na voz."));
|
|
205
|
+
}, ESPERA_DA_ENTRADA_MS);
|
|
206
|
+
prazo.unref?.();
|
|
207
|
+
this.#pendente = { resolver, rejeitar, prazo };
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
this.#bot.agir("voz.entrar", { code: code.toUpperCase() });
|
|
211
|
+
} catch (erro) {
|
|
212
|
+
// With no gateway connected there is nothing to wait for: fail now
|
|
213
|
+
// instead of leaving the caller hanging until the timeout.
|
|
214
|
+
this.#falharEntrada(erro instanceof Error ? erro.message : String(erro));
|
|
215
|
+
}
|
|
216
|
+
return promessa;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Leaves the voice room, if the bot is in one. */
|
|
220
|
+
sair(): void {
|
|
221
|
+
if (this.#canal) this.#bot.agir("voz.sair");
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Resolves a pending {@link entrar} call with the room information.
|
|
226
|
+
*
|
|
227
|
+
* @param entrada Data from the `voice.joined` event.
|
|
228
|
+
*/
|
|
229
|
+
#concluirEntrada(entrada: EntradaNaVoz): void {
|
|
230
|
+
const pendente = this.#pendente;
|
|
231
|
+
if (!pendente) return;
|
|
232
|
+
this.#pendente = null;
|
|
233
|
+
clearTimeout(pendente.prazo);
|
|
234
|
+
pendente.resolver(entrada);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Rejects a pending {@link entrar} call (server refusal, timeout, dropped gateway).
|
|
239
|
+
*
|
|
240
|
+
* @param mensagem Reason shown to the caller.
|
|
241
|
+
*/
|
|
242
|
+
#falharEntrada(mensagem: string): void {
|
|
243
|
+
const pendente = this.#pendente;
|
|
244
|
+
if (!pendente) return;
|
|
245
|
+
this.#pendente = null;
|
|
246
|
+
clearTimeout(pendente.prazo);
|
|
247
|
+
pendente.rejeitar(new Error(mensagem));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Returns the peer connection for `peerId`, creating it on first use.
|
|
252
|
+
*
|
|
253
|
+
* The offerer adds the three m-lines up front; the answerer reuses the ones
|
|
254
|
+
* from the received SDP. ICE candidates and remote tracks are wired here.
|
|
255
|
+
*
|
|
256
|
+
* @param peerId Peer id inside the room.
|
|
257
|
+
* @param options Whether this side is the offerer.
|
|
258
|
+
* @returns The existing or newly created connection.
|
|
259
|
+
*/
|
|
260
|
+
#conexao(peerId: string, { ofertante }: { ofertante: boolean }): ConexaoDeVoz {
|
|
261
|
+
const existente = this.#conexoes.get(peerId);
|
|
262
|
+
if (existente) return existente;
|
|
263
|
+
const conexao = this.#opcoes.transporte.criarConexao({
|
|
264
|
+
iceServers: this.#bot.iceServers,
|
|
265
|
+
});
|
|
266
|
+
// The offerer creates the three m-lines; the answerer reuses the ones from the SDP.
|
|
267
|
+
if (ofertante)
|
|
268
|
+
for (const tipo of ["audio", "audio", "video"] as const)
|
|
269
|
+
conexao.addTransceiver(tipo, { direction: "sendrecv" });
|
|
270
|
+
conexao.onicecandidate = (evento) => {
|
|
271
|
+
if (evento.candidate)
|
|
272
|
+
this.#enviar(peerId, { type: "candidate", candidate: evento.candidate } as SinalDeVoz);
|
|
273
|
+
};
|
|
274
|
+
conexao.ontrack = (evento) => this.#opcoes.aoReceberTrack?.(peerId, evento.track);
|
|
275
|
+
this.#conexoes.set(peerId, conexao);
|
|
276
|
+
return conexao;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Sends an offer to one peer, publishing the bot's track on the first m-line.
|
|
281
|
+
*
|
|
282
|
+
* @param peerId Peer to negotiate with.
|
|
283
|
+
*/
|
|
284
|
+
async #oferecer(peerId: string): Promise<void> {
|
|
285
|
+
try {
|
|
286
|
+
const conexao = this.#conexao(peerId, { ofertante: true });
|
|
287
|
+
const primeiro = conexao.getTransceivers()[0];
|
|
288
|
+
if (primeiro && !primeiro.sender.track)
|
|
289
|
+
await primeiro.sender.replaceTrack(this.track);
|
|
290
|
+
const oferta = await conexao.createOffer();
|
|
291
|
+
await conexao.setLocalDescription(oferta);
|
|
292
|
+
this.#enviarSdp(peerId, "offer", oferta.sdp);
|
|
293
|
+
} catch (erro) {
|
|
294
|
+
this.#log(`voz: oferta para ${peerId} falhou: ${mensagemDe(erro)}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Applies an incoming signal: offer, answer or ICE candidate.
|
|
300
|
+
*
|
|
301
|
+
* @param peerId Peer that sent the signal.
|
|
302
|
+
* @param sinal Raw WebRTC signal.
|
|
303
|
+
*/
|
|
304
|
+
async #aoSinal(peerId: string, sinal: SinalDeVoz): Promise<void> {
|
|
305
|
+
try {
|
|
306
|
+
if (sinal.type === "offer") return await this.#responder(peerId, sdpDe(sinal));
|
|
307
|
+
if (sinal.type === "answer") {
|
|
308
|
+
const sdp = sdpDe(sinal);
|
|
309
|
+
if (!sdp) return this.#log(`voz: resposta de ${peerId} veio sem SDP`);
|
|
310
|
+
const conexao = this.#conexao(peerId, { ofertante: false });
|
|
311
|
+
await conexao.setRemoteDescription({ type: "answer", sdp });
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (sinal.type === "candidate" && sinal.candidate) {
|
|
315
|
+
const conexao = this.#conexao(peerId, { ofertante: false });
|
|
316
|
+
await conexao.addIceCandidate(sinal.candidate);
|
|
317
|
+
}
|
|
318
|
+
} catch (erro) {
|
|
319
|
+
this.#log(`voz: sinal de ${peerId} falhou: ${mensagemDe(erro)}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Answers an offer.
|
|
325
|
+
*
|
|
326
|
+
* In a glare (both sides offered), the larger id ignores the other one's
|
|
327
|
+
* offer — the rule holds on both sides, so nobody ends up with two open
|
|
328
|
+
* negotiations.
|
|
329
|
+
*
|
|
330
|
+
* @param peerId Peer that sent the offer.
|
|
331
|
+
* @param sdp The offer's session description.
|
|
332
|
+
*/
|
|
333
|
+
async #responder(peerId: string, sdp: string): Promise<void> {
|
|
334
|
+
if (!sdp) return this.#log(`voz: oferta de ${peerId} veio sem SDP`);
|
|
335
|
+
const conexao = this.#conexao(peerId, { ofertante: false });
|
|
336
|
+
const emDisputa =
|
|
337
|
+
conexao.signalingState === "have-local-offer" &&
|
|
338
|
+
conexao.localDescription?.type === "offer";
|
|
339
|
+
if (emDisputa) {
|
|
340
|
+
if (this.#eu && this.#eu > peerId) return;
|
|
341
|
+
await conexao.setLocalDescription({ type: "rollback" });
|
|
342
|
+
}
|
|
343
|
+
await conexao.setRemoteDescription({ type: "offer", sdp });
|
|
344
|
+
// The SDP carries the three m-lines; forcing `sendrecv` is what lets the bot
|
|
345
|
+
// speak even when the offer arrived as `recvonly`.
|
|
346
|
+
for (const transceiver of conexao.getTransceivers()) transceiver.direction = "sendrecv";
|
|
347
|
+
const primeiro = conexao.getTransceivers()[0];
|
|
348
|
+
if (primeiro && !primeiro.sender.track)
|
|
349
|
+
await primeiro.sender.replaceTrack(this.track);
|
|
350
|
+
const resposta = await conexao.createAnswer();
|
|
351
|
+
await conexao.setLocalDescription(resposta);
|
|
352
|
+
this.#enviarSdp(peerId, "answer", resposta.sdp);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Sends an offer/answer with the SDP under **both** names the protocol knows:
|
|
357
|
+
* `sdp` (the bot contract's) and `description` (what the browser client
|
|
358
|
+
* reads). Sending only one left the mesh mute on one side — the symptom was a
|
|
359
|
+
* `setRemoteDescription(undefined)` swallowed by the client, with no error on
|
|
360
|
+
* the bot.
|
|
361
|
+
*
|
|
362
|
+
* @param peerId Destination peer.
|
|
363
|
+
* @param tipo `"offer"` or `"answer"`.
|
|
364
|
+
* @param sdp Session description to send.
|
|
365
|
+
*/
|
|
366
|
+
#enviarSdp(peerId: string, tipo: "offer" | "answer", sdp: string): void {
|
|
367
|
+
this.#enviar(peerId, { type: tipo, sdp, description: { type: tipo, sdp } });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Sends a raw signal to a peer through the gateway.
|
|
372
|
+
*
|
|
373
|
+
* @param peerId Destination peer.
|
|
374
|
+
* @param signal Signal payload.
|
|
375
|
+
*/
|
|
376
|
+
#enviar(peerId: string, signal: SinalDeVoz): void {
|
|
377
|
+
this.#bot.agir("voice.signal", { para: peerId, signal });
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Closes every peer connection the bot currently has open. */
|
|
381
|
+
#fecharTodas(): void {
|
|
382
|
+
for (const conexao of this.#conexoes.values()) conexao.close();
|
|
383
|
+
this.#conexoes.clear();
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Extracts the SDP from a signal, accepting both the bot's name and the browser
|
|
389
|
+
* client's.
|
|
390
|
+
*
|
|
391
|
+
* @param sinal Incoming signal.
|
|
392
|
+
* @returns The SDP, or `""` when the signal carries none.
|
|
393
|
+
*/
|
|
394
|
+
const sdpDe = (sinal: SinalDeVoz): string => {
|
|
395
|
+
if (typeof sinal.sdp === "string" && sinal.sdp) return sinal.sdp;
|
|
396
|
+
const descricao = sinal.description;
|
|
397
|
+
return typeof descricao?.sdp === "string" ? descricao.sdp : "";
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Turns any thrown value into a printable message.
|
|
402
|
+
*
|
|
403
|
+
* @param erro Value thrown or rejected.
|
|
404
|
+
* @returns `erro.message` for `Error`s, `String(erro)` otherwise.
|
|
405
|
+
*/
|
|
406
|
+
const mensagemDe = (erro: unknown): string =>
|
|
407
|
+
erro instanceof Error ? erro.message : String(erro);
|
package/src/wav.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// WAV of a pure tone — no sample file in the repository and no dependency.
|
|
2
|
+
//
|
|
3
|
+
// It serves two purposes: sending a voice "note" to the chat and exercising the
|
|
4
|
+
// upload path without needing a recorded audio file.
|
|
5
|
+
const CABECALHO = 44;
|
|
6
|
+
|
|
7
|
+
/** Shape of the generated WAV. Every field is optional. */
|
|
8
|
+
export interface OpcoesDoWav {
|
|
9
|
+
/** Duration in seconds (clamped to a 0.05 s minimum). */
|
|
10
|
+
segundos?: number;
|
|
11
|
+
/** Frequency in Hz. 440 is concert A. */
|
|
12
|
+
frequencia?: number;
|
|
13
|
+
/** 0…1. */
|
|
14
|
+
amplitude?: number;
|
|
15
|
+
/** Sample rate in Hz; 48 kHz matches the voice transport. */
|
|
16
|
+
sampleRate?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds an in-memory WAV file containing a sine wave.
|
|
21
|
+
*
|
|
22
|
+
* Output is 16-bit PCM, mono, little-endian — what any player and the client's
|
|
23
|
+
* `<audio>` element can play without conversion. Useful to post a quick voice
|
|
24
|
+
* message (`bot.enviarVoz`) or to test the upload path end to end.
|
|
25
|
+
*
|
|
26
|
+
* @param options Tone and container parameters; defaults to one second at 440 Hz.
|
|
27
|
+
* @returns The complete `.wav` file as bytes, header included.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```ts
|
|
31
|
+
* const wav = wavDeTom({ segundos: 2, frequencia: 330, amplitude: 0.2 });
|
|
32
|
+
* await bot.enviarVoz("MURAL", wav, { nome: "recado.wav" });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
export function wavDeTom({
|
|
36
|
+
segundos = 1,
|
|
37
|
+
frequencia = 440,
|
|
38
|
+
amplitude = 0.2,
|
|
39
|
+
sampleRate = 48_000,
|
|
40
|
+
}: OpcoesDoWav = {}): Uint8Array {
|
|
41
|
+
const quadros = Math.max(1, Math.round(sampleRate * Math.max(0.05, segundos)));
|
|
42
|
+
const bytes = new Uint8Array(CABECALHO + quadros * 2);
|
|
43
|
+
const visao = new DataView(bytes.buffer);
|
|
44
|
+
const texto = (posicao: number, valor: string) => {
|
|
45
|
+
for (let i = 0; i < valor.length; i += 1) visao.setUint8(posicao + i, valor.charCodeAt(i));
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
texto(0, "RIFF");
|
|
49
|
+
visao.setUint32(4, 36 + quadros * 2, true);
|
|
50
|
+
texto(8, "WAVE");
|
|
51
|
+
texto(12, "fmt ");
|
|
52
|
+
visao.setUint32(16, 16, true); // tamanho do bloco fmt
|
|
53
|
+
visao.setUint16(20, 1, true); // PCM
|
|
54
|
+
visao.setUint16(22, 1, true); // mono
|
|
55
|
+
visao.setUint32(24, sampleRate, true);
|
|
56
|
+
visao.setUint32(28, sampleRate * 2, true); // bytes por segundo
|
|
57
|
+
visao.setUint16(32, 2, true); // alinhamento do bloco
|
|
58
|
+
visao.setUint16(34, 16, true); // bits por amostra
|
|
59
|
+
texto(36, "data");
|
|
60
|
+
visao.setUint32(40, quadros * 2, true);
|
|
61
|
+
|
|
62
|
+
const limite = Math.max(0, Math.min(1, amplitude)) * 0x7fff;
|
|
63
|
+
const passo = (2 * Math.PI * frequencia) / sampleRate;
|
|
64
|
+
let fase = 0;
|
|
65
|
+
for (let i = 0; i < quadros; i += 1) {
|
|
66
|
+
visao.setInt16(CABECALHO + i * 2, Math.round(Math.sin(fase) * limite), true);
|
|
67
|
+
fase += passo;
|
|
68
|
+
if (fase > 2 * Math.PI) fase -= 2 * Math.PI;
|
|
69
|
+
}
|
|
70
|
+
return bytes;
|
|
71
|
+
}
|
package/src/wrtc.d.ts
ADDED