| <html data-bs-theme="dark"> |
| |
| <head> |
| <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet" |
| integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous"> |
| <script src="https://unpkg.com/pcm-player"></script> |
| </head> |
| |
| <body> |
| <nav class="navbar navbar-dark bg-primary"> |
| <div class="container"> |
| <span class="navbar-brand mb-0 h1">Bumble HFP Audio Gateway</span> |
| </div> |
| </nav> |
| <br> |
| |
| <div class="container"> |
| |
| <label class="form-label">Send AT Response</label> |
| <div class="input-group mb-3"> |
| <input type="text" class="form-control" placeholder="AT Response" aria-label="AT response" id="at_response"> |
| <button class="btn btn-primary" type="button" |
| onclick="send_at_response(document.getElementById('at_response').value)">Send</button> |
| </div> |
| |
| <div class="row"> |
| <div class="col-3"> |
| <label class="form-label">Speaker Volume</label> |
| <div class="input-group mb-3 col-auto"> |
| <input type="text" class="form-control" placeholder="0 - 15" aria-label="Speaker Volume" |
| id="speaker_volume"> |
| <button class="btn btn-primary" type="button" |
| onclick="send_at_response(`+VGS: ${document.getElementById('speaker_volume').value}`)">Set</button> |
| </div> |
| </div> |
| <div class="col-3"> |
| <label class="form-label">Mic Volume</label> |
| <div class="input-group mb-3 col-auto"> |
| <input type="text" class="form-control" placeholder="0 - 15" aria-label="Mic Volume" |
| id="mic_volume"> |
| <button class="btn btn-primary" type="button" |
| onclick="send_at_response(`+VGM: ${document.getElementById('mic_volume').value}`)">Set</button> |
| </div> |
| </div> |
| <div class="col-3"> |
| <label class="form-label">Browser Gain</label> |
| <input type="range" class="form-range" id="browser-gain" min="0" max="2" value="1" step="0.1" onchange="setGain()"> |
| </div> |
| </div> |
| |
| <div class="row"> |
| <div class="col-auto"> |
| <div class="input-group mb-3"> |
| <span class="input-group-text">Codec</span> |
| |
| <select class="form-select" id="codec"> |
| <option selected value="1">CVSD</option> |
| <option value="2">MSBC</option> |
| </select> |
| </div> |
| </div> |
| |
| <div class="col-auto"> |
| <button class="btn btn-primary" onclick="negotiate_codec()">Negotiate Codec</button> |
| </div> |
| <div class="col-auto"> |
| <button class="btn btn-primary" onclick="connect_sco()">Connect SCO</button> |
| </div> |
| <div class="col-auto"> |
| <button class="btn btn-primary" onclick="disconnect_sco()">Disconnect SCO</button> |
| </div> |
| <div class="col-auto"> |
| <button class="btn btn-danger" onclick="connectAudio()">Connect Audio</button> |
| </div> |
| </div> |
| |
| <hr> |
| |
| <div class="row"> |
| <h4>AG Indicators</h2> |
| <div class="col-3"> |
| <label class="form-label">call</label> |
| <div class="input-group mb-3 col-auto"> |
| <select class="form-select" id="call"> |
| <option selected value="0">Inactive</option> |
| <option value="1">Active</option> |
| </select> |
| <button class="btn btn-primary" type="button" onclick="update_ag_indicator('call')">Set</button> |
| </div> |
| </div> |
| <div class="col-3"> |
| <label class="form-label">callsetup</label> |
| <div class="input-group mb-3 col-auto"> |
| <select class="form-select" id="callsetup"> |
| <option selected value="0">Idle</option> |
| <option value="1">Incoming</option> |
| <option value="2">Outgoing</option> |
| <option value="3">Remote Alerted</option> |
| </select> |
| <button class="btn btn-primary" type="button" |
| onclick="update_ag_indicator('callsetup')">Set</button> |
| </div> |
| </div> |
| <div class="col-3"> |
| <label class="form-label">callheld</label> |
| <div class="input-group mb-3 col-auto"> |
| <select class="form-select" id="callsetup"> |
| <option selected value="0">0</option> |
| <option value="1">1</option> |
| <option value="2">2</option> |
| </select> |
| <button class="btn btn-primary" type="button" |
| onclick="update_ag_indicator('callheld')">Set</button> |
| </div> |
| </div> |
| <div class="col-3"> |
| <label class="form-label">signal</label> |
| <div class="input-group mb-3 col-auto"> |
| <select class="form-select" id="signal"> |
| <option selected value="0">0</option> |
| <option value="1">1</option> |
| <option value="2">2</option> |
| <option value="3">3</option> |
| <option value="4">4</option> |
| <option value="5">5</option> |
| </select> |
| <button class="btn btn-primary" type="button" |
| onclick="update_ag_indicator('signal')">Set</button> |
| </div> |
| </div> |
| <div class="col-3"> |
| <label class="form-label">roam</label> |
| <div class="input-group mb-3 col-auto"> |
| <select class="form-select" id="roam"> |
| <option selected value="0">0</option> |
| <option value="1">1</option> |
| </select> |
| <button class="btn btn-primary" type="button" onclick="update_ag_indicator('roam')">Set</button> |
| </div> |
| </div> |
| <div class="col-3"> |
| <label class="form-label">battchg</label> |
| <div class="input-group mb-3 col-auto"> |
| <select class="form-select" id="battchg"> |
| <option selected value="0">0</option> |
| <option value="1">1</option> |
| <option value="2">2</option> |
| <option value="3">3</option> |
| <option value="4">4</option> |
| <option value="5">5</option> |
| </select> |
| <button class="btn btn-primary" type="button" |
| onclick="update_ag_indicator('battchg')">Set</button> |
| </div> |
| </div> |
| <div class="col-3"> |
| <label class="form-label">service</label> |
| <div class="input-group mb-3 col-auto"> |
| <select class="form-select" id="service"> |
| <option selected value="0">0</option> |
| <option value="1">1</option> |
| </select> |
| <button class="btn btn-primary" type="button" |
| onclick="update_ag_indicator('service')">Set</button> |
| </div> |
| </div> |
| </div> |
| |
| <hr> |
| |
| <button class="btn btn-primary" onclick="send_at_response('+BVRA: 1')">Start Voice Assistant</button> |
| <button class="btn btn-primary" onclick="send_at_response('+BVRA: 0')">Stop Voice Assistant</button> |
| |
| <hr> |
| |
| |
| <h4>Calls</h4> |
| <div id="call-lists"> |
| <template id="call-template"> |
| <div class="row call-row"> |
| <div class="input-group mb-3"> |
| <label class="input-group-text">Index</label> |
| <input class="form-control call-index" value="1"> |
| |
| <label class="input-group-text">Number</label> |
| <input class="form-control call-number"> |
| |
| <label class="input-group-text">Direction</label> |
| <select class="form-select call-direction"> |
| <option selected value="0">Originated</option> |
| <option value="1">Terminated</option> |
| </select> |
| |
| <label class="input-group-text">Status</label> |
| <select class="form-select call-status"> |
| <option value="0">ACTIVE</option> |
| <option value="1">HELD</option> |
| <option value="2">DIALING</option> |
| <option value="3">ALERTING</option> |
| <option value="4">INCOMING</option> |
| <option value="5">WAITING</option> |
| </select> |
| <button class="btn btn-primary call-remover">❌</button> |
| </div> |
| </div> |
| </template> |
| </div> |
| |
| <button class="btn btn-primary" onclick="add_call()">➕ Add Call</button> |
| <button class="btn btn-primary" onclick="update_calls()">🗘 Update Calls</button> |
| |
| <hr> |
| |
| <div id="socketStateContainer" class="bg-body-tertiary p-3 rounded-2"> |
| <h3>Log</h3> |
| <code id="log" style="white-space: pre-line;"></code> |
| </div> |
| </div> |
| |
| |
| <script> |
| let atResponseInput = document.getElementById("at_response") |
| let gainInput = document.getElementById('browser-gain') |
| let log = document.getElementById("log") |
| let socket = new WebSocket('ws://localhost:8888'); |
| let sampleRate = 0; |
| let player; |
| |
| socket.binaryType = "arraybuffer"; |
| socket.onopen = _ => { |
| log.textContent += 'SOCKET OPEN\n' |
| } |
| socket.onclose = _ => { |
| log.textContent += 'SOCKET CLOSED\n' |
| } |
| socket.onerror = (error) => { |
| log.textContent += 'SOCKET ERROR\n' |
| console.log(`ERROR: ${error}`) |
| } |
| socket.onmessage = function (message) { |
| if (typeof message.data === 'string' || message.data instanceof String) { |
| log.textContent += `<-- ${event.data}\n` |
| const jsonMessage = JSON.parse(event.data) |
| |
| if (jsonMessage.type == 'speaker_volume') { |
| document.getElementById('speaker_volume').value = jsonMessage.level; |
| } else if (jsonMessage.type == 'microphone_volume') { |
| document.getElementById('microphone_volume').value = jsonMessage.level; |
| } else if (jsonMessage.type == 'sco_state_change') { |
| sampleRate = jsonMessage.sample_rate; |
| console.log(sampleRate); |
| if (player != null) { |
| player = new PCMPlayer({ |
| inputCodec: 'Int16', |
| channels: 1, |
| sampleRate: sampleRate, |
| flushTime: 7.5, |
| }); |
| player.volume(gainInput.value); |
| } |
| } |
| } else { |
| // BINARY audio data. |
| if (player == null) return; |
| player.feed(message.data); |
| } |
| }; |
| |
| function send(message) { |
| if (socket && socket.readyState == WebSocket.OPEN) { |
| let jsonMessage = JSON.stringify(message) |
| log.textContent += `--> ${jsonMessage}\n` |
| socket.send(jsonMessage) |
| } else { |
| log.textContent += 'NOT CONNECTED\n' |
| } |
| } |
| |
| function send_at_response(response) { |
| send({ type: 'at_response', response: response }) |
| } |
| |
| function update_ag_indicator(indicator) { |
| const value = document.getElementById(indicator).value |
| send({ type: 'ag_indicator', indicator: indicator, value: value }) |
| } |
| |
| function connect_sco() { |
| send({ type: 'connect_sco' }) |
| } |
| |
| function negotiate_codec() { |
| const codec = document.getElementById('codec').value |
| send({ type: 'negotiate_codec', codec: codec }) |
| } |
| |
| function disconnect_sco() { |
| send({ type: 'disconnect_sco' }) |
| } |
| |
| function add_call() { |
| let callLists = document.getElementById('call-lists'); |
| let template = document.getElementById('call-template'); |
| |
| let newNode = document.importNode(template.content, true); |
| newNode.querySelector('.call-remover').onclick = function (event) { |
| event.target.closest('.call-row').remove(); |
| } |
| callLists.appendChild(newNode); |
| } |
| |
| function update_calls() { |
| let callLists = document.getElementById('call-lists'); |
| send({ |
| type: 'update_calls', |
| calls: Array.from( |
| callLists.querySelectorAll('.call-row')).map( |
| function (element) { |
| return { |
| index: element.querySelector('.call-index').value, |
| number: element.querySelector('.call-number').value, |
| direction: element.querySelector('.call-direction').value, |
| status: element.querySelector('.call-status').value, |
| } |
| } |
| ), |
| } |
| ) |
| } |
| |
| function connectAudio() { |
| player = new PCMPlayer({ |
| inputCodec: 'Int16', |
| channels: 1, |
| sampleRate: sampleRate, |
| flushTime: 7.5, |
| }); |
| player.volume(gainInput.value); |
| } |
| |
| function setGain() { |
| if (player != null) { |
| player.volume(gainInput.value); |
| } |
| } |
| </script> |
| </div> |
| </body> |
| |
| </html> |