Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
675348794c | ||
|
|
9e1b025c8f | ||
|
|
a351116f58 | ||
|
|
0cbad12e49 | ||
|
|
b0f7d294d8 |
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 Benoit S
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -114,8 +114,6 @@ Communiquez via smartphone (PWA) en WiFi, le serveur fait le pont avec l'install
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 Dépannage : "Connexion impossible"
|
## 🐛 Dépannage : "Connexion impossible"
|
||||||
|
|
||||||
**Cause** : Clés LiveKit non configurées ou invalides.
|
**Cause** : Clés LiveKit non configurées ou invalides.
|
||||||
@@ -132,7 +130,9 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
|
|||||||
|
|
||||||
## 📚 Documentation
|
## 📚 Documentation
|
||||||
|
|
||||||
- **[README-PORTABLE.md](README-PORTABLE.md)** - 🆕 **Guide déploiement portable** (zéro config)
|
- **[DESKTOP-APP.md](DESKTOP-APP.md)** - Application desktop Electron (dashboard + config)
|
||||||
|
- **[README-PORTABLE.md](README-PORTABLE.md)** - Guide déploiement portable (zéro config)
|
||||||
|
- **[SSL-SETUP.md](SSL-SETUP.md)** - Configuration certificats HTTPS locaux
|
||||||
- **[NETWORK_SETUP.md](NETWORK_SETUP.md)** - Configuration réseau multi-appareils
|
- **[NETWORK_SETUP.md](NETWORK_SETUP.md)** - Configuration réseau multi-appareils
|
||||||
- **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local)
|
- **[docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)** - Configuration LiveKit (Cloud + Local)
|
||||||
- **[CLAUDE.md](CLAUDE.md)** - Documentation développement complète
|
- **[CLAUDE.md](CLAUDE.md)** - Documentation développement complète
|
||||||
@@ -144,10 +144,11 @@ Voir le guide complet : [docs/SETUP_LIVEKIT.md](docs/SETUP_LIVEKIT.md)
|
|||||||
|
|
||||||
- ✅ **Phase 1** : MVP fonctionnel (WebRTC + PTT)
|
- ✅ **Phase 1** : MVP fonctionnel (WebRTC + PTT)
|
||||||
- ✅ **Phase 2** : Fonctionnalités avancées (groupes, routing, admin)
|
- ✅ **Phase 2** : Fonctionnalités avancées (groupes, routing, admin)
|
||||||
- 🆕 **Portable** : Installation zéro-config macOS/Linux
|
- ✅ **Portable** : Installation zéro-config macOS/Linux
|
||||||
|
- ✅ **Desktop** : Application Electron avec dashboard complet
|
||||||
- ⏳ **Phase 3** : Intégrations audio pro (Dante, AES67)
|
- ⏳ **Phase 3** : Intégrations audio pro (Dante, AES67)
|
||||||
|
|
||||||
**Version actuelle** : 0.2.0 (Portable - production-ready)
|
**Version actuelle** : 0.3.0 (Desktop App)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -299,15 +300,17 @@ project/
|
|||||||
│ ├── index.js # Point d'entrée unique
|
│ ├── index.js # Point d'entrée unique
|
||||||
│ ├── livekit-server # Binaire (téléchargé à l'install)
|
│ ├── livekit-server # Binaire (téléchargé à l'install)
|
||||||
│ ├── bridge/
|
│ ├── bridge/
|
||||||
│ │ ├── audio.js # Détection + abstraction
|
│ │ ├── AudioBridge.js # Classe principale
|
||||||
│ │ ├── backends/
|
│ │ ├── AudioBridgeManager.js # Gestion cycle de vie
|
||||||
│ │ │ ├── jack.js
|
│ │ ├── LiveKitClient.js # Connexion SFU
|
||||||
│ │ │ ├── pipewire.js
|
│ │ ├── OpusCodec.js # Transcodage PCM ↔ Opus
|
||||||
│ │ │ ├── coreaudio.js
|
│ │ ├── JitterBuffer.js # Buffer 40ms
|
||||||
│ │ │ └── wasapi.js
|
│ │ ├── ServerAudioUser.js # Utilisateur audio serveur
|
||||||
│ │ ├── livekit.js
|
│ │ └── backends/
|
||||||
│ │ ├── opus.js
|
│ │ ├── CoreAudioBackend.js
|
||||||
│ │ └── jitter.js
|
│ │ ├── JACKBackend.js
|
||||||
|
│ │ ├── PipeWireBackend.js
|
||||||
|
│ │ └── WASAPIBackend.js
|
||||||
│ ├── api/ # Admin REST
|
│ ├── api/ # Admin REST
|
||||||
│ └── config/
|
│ └── config/
|
||||||
│ └── config.yaml
|
│ └── config.yaml
|
||||||
|
|||||||
+14
-2
@@ -454,7 +454,13 @@ app.whenReady().then(async () => {
|
|||||||
if (users.find(u => u.name === name)) {
|
if (users.find(u => u.name === name)) {
|
||||||
return { success: false, error: `Un utilisateur "${name}" existe déjà` };
|
return { success: false, error: `Un utilisateur "${name}" existe déjà` };
|
||||||
}
|
}
|
||||||
const user = { name, group, input_channel: parseInt(input_channel), output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null };
|
const parsedInput = input_channel !== null && input_channel !== undefined ? parseInt(input_channel) : null;
|
||||||
|
const user = {
|
||||||
|
name,
|
||||||
|
group,
|
||||||
|
input_channel: parsedInput,
|
||||||
|
output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null
|
||||||
|
};
|
||||||
config.server_audio_users = [...users, user];
|
config.server_audio_users = [...users, user];
|
||||||
writeConfig(config);
|
writeConfig(config);
|
||||||
return { success: true, user };
|
return { success: true, user };
|
||||||
@@ -469,7 +475,13 @@ app.whenReady().then(async () => {
|
|||||||
const users = config.server_audio_users || [];
|
const users = config.server_audio_users || [];
|
||||||
const idx = users.findIndex(u => u.name === name);
|
const idx = users.findIndex(u => u.name === name);
|
||||||
if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` };
|
if (idx === -1) return { success: false, error: `Utilisateur "${name}" introuvable` };
|
||||||
config.server_audio_users[idx] = { name, group, input_channel: parseInt(input_channel), output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null };
|
const parsedInput = input_channel !== null && input_channel !== undefined ? parseInt(input_channel) : null;
|
||||||
|
config.server_audio_users[idx] = {
|
||||||
|
name,
|
||||||
|
group,
|
||||||
|
input_channel: parsedInput,
|
||||||
|
output_channel: output_channel !== null && output_channel !== '' ? parseInt(output_channel) : null
|
||||||
|
};
|
||||||
writeConfig(config);
|
writeConfig(config);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+21
-12
@@ -803,7 +803,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const action = btn.dataset.sauAction;
|
const action = btn.dataset.sauAction;
|
||||||
const name = btn.dataset.sauName;
|
const name = btn.dataset.sauName;
|
||||||
if (action === 'edit') {
|
if (action === 'edit') {
|
||||||
await editServerAudioUser(name, btn.dataset.sauGroup, parseInt(btn.dataset.sauInput), parseInt(btn.dataset.sauOutput));
|
await editServerAudioUser(name, btn.dataset.sauGroup, btn.dataset.sauInput, btn.dataset.sauOutput);
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
await deleteServerAudioUser(name);
|
await deleteServerAudioUser(name);
|
||||||
}
|
}
|
||||||
@@ -874,9 +874,7 @@ function buildChannelOptions(dir) {
|
|||||||
label: names[i] ? `Ch ${i}: ${names[i]}` : `Ch ${i}`
|
label: names[i] ? `Ch ${i}: ${names[i]}` : `Ch ${i}`
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (dir === 'output') {
|
opts.unshift({ value: '', label: dir === 'input' ? 'Aucune entrée' : 'Aucune sortie' });
|
||||||
opts.unshift({ value: '', label: 'Aucune sortie' });
|
|
||||||
}
|
|
||||||
|
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
@@ -890,8 +888,8 @@ async function addServerAudioUser() {
|
|||||||
const outOpts = buildChannelOptions('output');
|
const outOpts = buildChannelOptions('output');
|
||||||
|
|
||||||
const inputField = inOpts
|
const inputField = inOpts
|
||||||
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '0' }
|
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: '' }
|
||||||
: { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: 0, min: 0, max: 63 };
|
: { name: 'input_channel', label: 'Canal entrée (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
|
||||||
const outputField = outOpts
|
const outputField = outOpts
|
||||||
? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: '' }
|
? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: '' }
|
||||||
: { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
|
: { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: '', min: 0, max: 63 };
|
||||||
@@ -912,7 +910,7 @@ async function addServerAudioUser() {
|
|||||||
const res = await window.electronAPI.serverAudioUsers.create({
|
const res = await window.electronAPI.serverAudioUsers.create({
|
||||||
name: result.name.trim(),
|
name: result.name.trim(),
|
||||||
group: result.group,
|
group: result.group,
|
||||||
input_channel: parseInt(result.input_channel),
|
input_channel: result.input_channel !== '' ? parseInt(result.input_channel) : null,
|
||||||
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
|
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -931,10 +929,11 @@ async function editServerAudioUser(name, group, input_channel, output_channel) {
|
|||||||
const inOpts = buildChannelOptions('input');
|
const inOpts = buildChannelOptions('input');
|
||||||
const outOpts = buildChannelOptions('output');
|
const outOpts = buildChannelOptions('output');
|
||||||
|
|
||||||
|
const inputDefault = input_channel !== null && input_channel !== undefined && input_channel !== 'null' ? String(input_channel) : '';
|
||||||
const inputField = inOpts
|
const inputField = inOpts
|
||||||
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: String(input_channel) }
|
? { name: 'input_channel', label: 'Canal d\'entrée', type: 'select', options: inOpts, default: inputDefault }
|
||||||
: { name: 'input_channel', label: 'Canal entrée (index)', type: 'number', default: input_channel, min: 0, max: 63 };
|
: { name: 'input_channel', label: 'Canal entrée (index, vide = aucune)', type: 'number', default: inputDefault, min: 0, max: 63 };
|
||||||
const outputDefault = output_channel !== null && output_channel !== undefined ? String(output_channel) : '';
|
const outputDefault = output_channel !== null && output_channel !== undefined && output_channel !== 'null' ? String(output_channel) : '';
|
||||||
const outputField = outOpts
|
const outputField = outOpts
|
||||||
? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: outputDefault }
|
? { name: 'output_channel', label: 'Canal de sortie', type: 'select', options: outOpts, default: outputDefault }
|
||||||
: { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: outputDefault, min: 0, max: 63 };
|
: { name: 'output_channel', label: 'Canal sortie (index, vide = aucune)', type: 'number', default: outputDefault, min: 0, max: 63 };
|
||||||
@@ -954,7 +953,7 @@ async function editServerAudioUser(name, group, input_channel, output_channel) {
|
|||||||
const res = await window.electronAPI.serverAudioUsers.update({
|
const res = await window.electronAPI.serverAudioUsers.update({
|
||||||
name,
|
name,
|
||||||
group: result.group,
|
group: result.group,
|
||||||
input_channel: parseInt(result.input_channel),
|
input_channel: result.input_channel !== '' ? parseInt(result.input_channel) : null,
|
||||||
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
|
output_channel: result.output_channel !== '' ? parseInt(result.output_channel) : null
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1118,6 +1117,15 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas
|
|||||||
<select id="modal-field-${field.name}" class="form-control">${optionsHtml}</select>
|
<select id="modal-field-${field.name}" class="form-control">${optionsHtml}</select>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
if (field.type === 'checkbox') {
|
||||||
|
return `
|
||||||
|
<div class="form-group form-group-check">
|
||||||
|
<label class="check-label">
|
||||||
|
<input type="checkbox" id="modal-field-${field.name}" ${field.default !== false ? 'checked' : ''}>
|
||||||
|
${escapeHtml(field.label)}
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
return `
|
return `
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>${escapeHtml(field.label)}</label>
|
<label>${escapeHtml(field.label)}</label>
|
||||||
@@ -1155,7 +1163,8 @@ function showModal({ title, fields = [], confirmLabel = 'Confirmer', confirmClas
|
|||||||
const result = {};
|
const result = {};
|
||||||
fields.forEach(f => {
|
fields.forEach(f => {
|
||||||
const input = document.getElementById(`modal-field-${f.name}`);
|
const input = document.getElementById(`modal-field-${f.name}`);
|
||||||
result[f.name] = input ? input.value : '';
|
if (!input) { result[f.name] = ''; return; }
|
||||||
|
result[f.name] = f.type === 'checkbox' ? input.checked : input.value;
|
||||||
});
|
});
|
||||||
cleanup(); resolve(result);
|
cleanup(); resolve(result);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1044,6 +1044,29 @@ body {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.form-group-check {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-label input[type="checkbox"] {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
accent-color: var(--accent-primary);
|
||||||
|
}
|
||||||
|
|
||||||
/* Routing actions bar */
|
/* Routing actions bar */
|
||||||
|
|
||||||
.routing-actions {
|
.routing-actions {
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ export class AudioBridge extends EventEmitter {
|
|||||||
groupId: userConfig.groupId,
|
groupId: userConfig.groupId,
|
||||||
inputChannel: userConfig.inputChannel,
|
inputChannel: userConfig.inputChannel,
|
||||||
outputChannel: userConfig.outputChannel,
|
outputChannel: userConfig.outputChannel,
|
||||||
|
publish: userConfig.publish !== false,
|
||||||
liveKitUrl: this.options.liveKitUrl,
|
liveKitUrl: this.options.liveKitUrl,
|
||||||
token: userConfig.token,
|
token: userConfig.token,
|
||||||
sampleRate: this.options.sampleRate,
|
sampleRate: this.options.sampleRate,
|
||||||
@@ -338,7 +339,10 @@ export class AudioBridge extends EventEmitter {
|
|||||||
|
|
||||||
await user.start();
|
await user.start();
|
||||||
this.serverAudioUsers.set(userConfig.name, user);
|
this.serverAudioUsers.set(userConfig.name, user);
|
||||||
console.log(`✓ Server audio user "${userConfig.name}" démarré (entrée canal ${userConfig.inputChannel} → sortie canal ${userConfig.outputChannel}, room: ${userConfig.groupId})`);
|
const modeStr = userConfig.publish !== false
|
||||||
|
? `canal ${userConfig.inputChannel} → sortie canal ${userConfig.outputChannel ?? 'aucune'}`
|
||||||
|
: `écoute seule → sortie canal ${userConfig.outputChannel ?? 'aucune'}`;
|
||||||
|
console.log(`✓ Server audio user "${userConfig.name}" démarré (${modeStr}, room: ${userConfig.groupId})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`✓ ${this.serverAudioUsers.size} server audio user(s) initialisés`);
|
console.log(`✓ ${this.serverAudioUsers.size} server audio user(s) initialisés`);
|
||||||
|
|||||||
@@ -66,10 +66,14 @@ class AudioBridgeManager extends EventEmitter {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rawInputChannel = user.input_channel ?? user.inputChannel ?? null;
|
||||||
|
const inputChannel = rawInputChannel !== null && rawInputChannel !== undefined ? rawInputChannel : null;
|
||||||
|
const publish = inputChannel !== null;
|
||||||
|
|
||||||
token.addGrant({
|
token.addGrant({
|
||||||
room: groupId,
|
room: groupId,
|
||||||
roomJoin: true,
|
roomJoin: true,
|
||||||
canPublish: true,
|
canPublish: publish,
|
||||||
canSubscribe: true,
|
canSubscribe: true,
|
||||||
canPublishData: true
|
canPublishData: true
|
||||||
});
|
});
|
||||||
@@ -81,8 +85,9 @@ class AudioBridgeManager extends EventEmitter {
|
|||||||
serverAudioUsers.push({
|
serverAudioUsers.push({
|
||||||
name: user.name,
|
name: user.name,
|
||||||
groupId,
|
groupId,
|
||||||
inputChannel: user.input_channel ?? user.inputChannel ?? 0,
|
inputChannel,
|
||||||
outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null,
|
outputChannel: outputChannel !== null && outputChannel !== undefined ? outputChannel : null,
|
||||||
|
publish,
|
||||||
token: jwt
|
token: jwt
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ class ServerAudioUser extends EventEmitter {
|
|||||||
super();
|
super();
|
||||||
|
|
||||||
this.name = options.name;
|
this.name = options.name;
|
||||||
this.inputChannel = parseInt(options.inputChannel, 10);
|
this.inputChannel = (options.inputChannel !== null && options.inputChannel !== undefined)
|
||||||
|
? parseInt(options.inputChannel, 10)
|
||||||
|
: null;
|
||||||
this.outputChannel = (options.outputChannel !== null && options.outputChannel !== undefined)
|
this.outputChannel = (options.outputChannel !== null && options.outputChannel !== undefined)
|
||||||
? parseInt(options.outputChannel, 10)
|
? parseInt(options.outputChannel, 10)
|
||||||
: null;
|
: null;
|
||||||
|
this.publish = options.publish !== false; // false = écoute seule
|
||||||
this.groupId = options.groupId;
|
this.groupId = options.groupId;
|
||||||
this.frameSize = options.frameSize || 960;
|
this.frameSize = options.frameSize || 960;
|
||||||
this.sampleRate = options.sampleRate || 48000;
|
this.sampleRate = options.sampleRate || 48000;
|
||||||
@@ -45,7 +48,8 @@ class ServerAudioUser extends EventEmitter {
|
|||||||
|
|
||||||
_setupClientEvents() {
|
_setupClientEvents() {
|
||||||
this.client.on('connected', () => {
|
this.client.on('connected', () => {
|
||||||
console.log(`[ServerAudioUser:${this.name}] Connecté à room "${this.groupId}" (in:${this.inputChannel} → out:${this.outputChannel ?? 'aucune'})`);
|
const mode = this.publish ? `in:${this.inputChannel} → out:${this.outputChannel ?? 'aucune'}` : `écoute → out:${this.outputChannel ?? 'aucune'}`;
|
||||||
|
console.log(`[ServerAudioUser:${this.name}] Connecté à room "${this.groupId}" (${mode})`);
|
||||||
this.emit('connected');
|
this.emit('connected');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ class ServerAudioUser extends EventEmitter {
|
|||||||
* @param {Float32Array} float32Data - Données PCM normalisées [-1.0, 1.0]
|
* @param {Float32Array} float32Data - Données PCM normalisées [-1.0, 1.0]
|
||||||
*/
|
*/
|
||||||
sendAudio(float32Data) {
|
sendAudio(float32Data) {
|
||||||
if (!this.client.isConnected) return;
|
if (!this.publish || !this.client.isConnected) return;
|
||||||
|
|
||||||
const pcmBuffer = this._float32ToBuffer(float32Data);
|
const pcmBuffer = this._float32ToBuffer(float32Data);
|
||||||
this.client.sendAudioData(pcmBuffer);
|
this.client.sendAudioData(pcmBuffer);
|
||||||
|
|||||||
Reference in New Issue
Block a user