codec2 audio stream working over reticulum link

This commit is contained in:
liamcottle
2024-05-20 16:06:30 +12:00
parent fd243b998f
commit c3d972afdf
11 changed files with 13363 additions and 0 deletions

View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

View File

@@ -0,0 +1,127 @@
class Codec2Lib {
static arrayBufferToBase64(buffer) {
let binary = "";
let bytes = new Uint8Array(buffer);
for (let byte of bytes) {
binary += String.fromCharCode(byte);
}
return window.btoa(binary);
}
static base64ToArrayBuffer(base64) {
let binary = window.atob(base64);
let bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes.buffer;
}
static readFileAsArrayBuffer(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
resolve(reader.result);
};
reader.readAsArrayBuffer(file);
});
}
static runDecode(mode, data) {
return new Promise((resolve, reject) => {
const module = {
arguments: [mode, "input.bit", "output.raw"],
preRun: () => {
module.FS.writeFile("input.bit", new Uint8Array(data));
},
postRun: () => {
let buffer = module.FS.readFile("output.raw", {
encoding: "binary",
});
resolve(buffer);
},
};
createC2Dec(module);
});
}
static runEncode(mode, data) {
return new Promise((resolve, reject) => {
const module = {
arguments: [mode, "input.raw", "output.bit"],
preRun: () => {
module.FS.writeFile("input.raw", new Uint8Array(data));
},
postRun: () => {
let buffer = module.FS.readFile("output.bit", {
encoding: "binary",
});
resolve(buffer);
},
};
createC2Enc(module);
});
}
static rawToWav(buffer) {
return new Promise((resolve, reject) => {
const module = {
arguments: [
"-r",
"8000",
"-L",
"-e",
"signed-integer",
"-b",
"16",
"-c",
"1",
"input.raw",
"output.wav",
],
preRun: () => {
module.FS.writeFile("input.raw", new Uint8Array(buffer));
},
postRun: () => {
let output = module.FS.readFile("output.wav", {
encoding: "binary",
});
resolve(output);
},
};
SOXModule(module);
});
}
static audioFileToRaw(buffer, filename) {
return new Promise((resolve, reject) => {
const module = {
arguments: [
filename,
"-r",
"8000",
"-L",
"-e",
"signed-integer",
"-b",
"16",
"-c",
"1",
"output.raw",
],
preRun: () => {
module.FS.writeFile(filename, new Uint8Array(buffer));
},
postRun: () => {
let output = module.FS.readFile("output.raw", {
encoding: "binary",
});
resolve(output);
},
};
SOXModule(module);
});
}
}

View File

@@ -0,0 +1,127 @@
<html>
<body>
<div>
<div style="margin-bottom:1rem;">
<div>Select a *.wav audio file.</div>
<input id="file-input" type="file" accept="audio/wav"/>
</div>
<div style="margin-bottom:1rem;">
<span>Select Codec2 Mode:</span>
<select id="codec-mode">
<option value="3200">3200</option>
<option value="2400">2400</option>
<option value="1600">1600</option>
<option value="1400">1400</option>
<option value="1300">1300</option>
<option value="1200">1200</option>
<option value="700C" selected>700C</option>
<option value="450">450</option>
<option value="450PWB">450PWB</option>
</select>
</div>
<div style="margin-bottom:1rem;">
<div>Click to encode audio file as Codec2</div>
<button type="submit" onclick="encode()">Encode</button>
</div>
<div style="margin-bottom:1rem;">
<div>Codec2 audio represented as Base64</div>
<textarea id="encoded-output" style="width:500px" rows="8"></textarea>
</div>
<div style="margin-bottom:1rem;">
<div>Click to decode Codec2 audio back to WAVE audio</div>
<button type="submit" onclick="decode()">Decode</button>
</div>
<div style="margin-bottom:1rem;">
<div>Decoded audio available to listen to</div>
<audio id="decoded-audio" controls></audio>
</div>
<div style="margin-bottom:1rem;">
<div>Input File Size: <span id="input-size">0 Bytes</span></div>
<div>Encoded Data Size: <span id="encoded-size">0 Bytes</span></div>
<div>Decoded Data Size: <span id="decoded-size">0 Bytes</span></div>
</div>
</div>
<script src="c2enc.js"></script>
<script src="c2dec.js"></script>
<script src="sox.js"></script>
<script src="codec2-lib.js"></script>
<script>
// find elements
const codecModeElement = document.getElementById("codec-mode");
const encodedOutputElement = document.getElementById("encoded-output");
const fileInputElement = document.getElementById("file-input");
const decodedAudioElement = document.getElementById("decoded-audio");
const inputSizeElement = document.getElementById("input-size");
const encodedSizeElement = document.getElementById("encoded-size");
const decodedSizeElement = document.getElementById("decoded-size");
// update file size stats on change
fileInputElement.onchange = function() {
if(fileInputElement.files.length > 0){
const file = fileInputElement.files[0];
inputSizeElement.innerText = formatBytes(file.size);
}
}
async function encode() {
const file = fileInputElement.files[0];
if(!file){
alert("select a file first");
return;
}
const mode = codecModeElement.value;
const buffer = await Codec2Lib.readFileAsArrayBuffer(file);
const rawBuffer = await Codec2Lib.audioFileToRaw(buffer, file.name || "input.wav");
const encoded = await Codec2Lib.runEncode(mode, rawBuffer);
encodedOutputElement.value = Codec2Lib.arrayBufferToBase64(encoded);
inputSizeElement.innerText = formatBytes(file.size);
encodedSizeElement.innerText = formatBytes(encoded.length);
}
async function decode() {
const mode = codecModeElement.value;
const input = encodedOutputElement.value;
const encoded = Codec2Lib.base64ToArrayBuffer(input);
const decodedRaw = await Codec2Lib.runDecode(mode, encoded);
const decodedWav = await Codec2Lib.rawToWav(decodedRaw);
decodedAudioElement.src = URL.createObjectURL(new Blob([decodedWav], { type: "audio/wav" }));
decodedSizeElement.innerText = formatBytes(decodedWav.length);
}
function formatBytes(bytes) {
if(bytes === 0){
return '0 Bytes';
}
const k = 1024;
const decimals = 0;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
</script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
class AudioProcessor extends AudioWorkletProcessor {
constructor() {
super();
this.bufferSize = 4096; // Adjust the buffer size as needed
this.sampleRate = 8000; // Target sample rate
this.inputBuffer = new Float32Array(this.bufferSize);
this.bufferIndex = 0;
}
process(inputs, outputs, parameters) {
const input = inputs[0];
if (input.length > 0) {
const inputData = input[0];
for (let i = 0; i < inputData.length; i++) {
if (this.bufferIndex < this.bufferSize) {
this.inputBuffer[this.bufferIndex++] = inputData[i];
}
if (this.bufferIndex === this.bufferSize) {
// Downsample the buffer and send to the main thread
const downsampledBuffer = this.downsampleBuffer(this.inputBuffer, this.sampleRate);
this.port.postMessage(downsampledBuffer);
this.bufferIndex = 0;
}
}
}
return true;
}
downsampleBuffer(buffer, targetSampleRate) {
if (targetSampleRate === this.sampleRate) {
return buffer;
}
const sampleRateRatio = this.sampleRate / targetSampleRate;
const newLength = Math.round(buffer.length / sampleRateRatio);
const result = new Float32Array(newLength);
let offsetResult = 0;
let offsetBuffer = 0;
while (offsetResult < result.length) {
const nextOffsetBuffer = Math.round((offsetResult + 1) * sampleRateRatio);
let accum = 0;
let count = 0;
for (let i = offsetBuffer; i < nextOffsetBuffer && i < buffer.length; i++) {
accum += buffer[i];
count++;
}
result[offsetResult] = accum / count;
offsetResult++;
offsetBuffer = nextOffsetBuffer;
}
return result;
}
}
registerProcessor('audio-processor', AudioProcessor);

View File

File diff suppressed because it is too large Load Diff

View File

Binary file not shown.

231
public/call.html Normal file
View File

@@ -0,0 +1,231 @@
<html>
<body>
<div>
<div>
<button onclick="startStreaming()">Start Streaming</button>
<button onclick="stopStreaming()">Stop Streaming</button>
<button onclick="startListening()">Start Listening</button>
<button onclick="stopListening()">Stop Listening</button>
</div>
<div>
<select id="codec-mode">
<option value="3200">3200</option>
<option value="2400">2400</option>
<option value="1600">1600</option>
<option value="1400">1400</option>
<option value="1300">1300</option>
<option value="1200" selected>1200</option>
<option value="700C">700C</option>
<option value="450">450</option>
<option value="450PWB">450PWB</option>
</select>
</div>
<div>Encoded Bytes Sent: <span id="encoded-bytes-sent"></span></div>
</div>
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
<script src="assets/js/codec2-emscripten/sox.js"></script>
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
<script>
// find elements
const codecModeElement = document.getElementById("codec-mode");
const encodedBytesSentElement = document.getElementById("encoded-bytes-sent");
var encodedBytesSent = 0;
let audioContext;
let mediaStreamSource;
let audioWorkletNode;
let microphoneMediaStream;
var callWebsocket = null;
var listenWebsocket = null;
async function startStreaming() {
try {
// reset stats
encodedBytesSent = 0;
// ask who to call
const destinationHash = prompt("Enter destination hash to call");
if(!destinationHash){
return;
}
// connect to websocket
callWebsocket = new WebSocket(location.origin.replace(/^http/, 'ws') + "/call/initiate/" + destinationHash);
callWebsocket.onopen = () => {
console.log("connected to websocket");
};
// load audio worklet module
audioContext = new AudioContext({ sampleRate: 8000 });
await audioContext.audioWorklet.addModule('assets/js/codec2-emscripten/processor.js');
audioWorkletNode = new AudioWorkletNode(audioContext, 'audio-processor');
// handle audio received from audio worklet
audioWorkletNode.port.onmessage = async (event) => {
// convert audio received from worklet processor to wav
const buffer = encodeWAV(event.data, 8000);
// convert wav audio to codec2
const rawBuffer = await Codec2Lib.audioFileToRaw(buffer, "audio.wav");
const encoded = await Codec2Lib.runEncode(codecModeElement.value, rawBuffer);
// update stats
encodedBytesSent += encoded.length;
encodedBytesSentElement.innerText = formatBytes(encodedBytesSent);
// send encoded audio to websocket
if(callWebsocket.readyState === WebSocket.OPEN){
callWebsocket.send(encoded);
}
};
// request access to the microphone
microphoneMediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });
// send mic audio to audio worklet
mediaStreamSource = audioContext.createMediaStreamSource(microphoneMediaStream);
mediaStreamSource.connect(audioWorkletNode);
} catch(error) {
alert(error);
console.log(error);
}
}
function stopStreaming() {
// disconnect websocket
if(callWebsocket){
callWebsocket.close()
}
// disconnect media stream source
if(mediaStreamSource){
mediaStreamSource.disconnect();
}
// stop using microphone
if(microphoneMediaStream){
microphoneMediaStream.getTracks().forEach(track => track.stop());
}
// disconnect the audio worklet node
if(audioWorkletNode){
audioWorkletNode.disconnect();
}
// close audio context
if(audioContext){
audioContext.close();
}
}
async function startListening() {
// connect to websocket to get codec2 packets
listenWebsocket = new WebSocket(location.origin.replace(/^http/, 'ws') + "/call/listen");
listenWebsocket.onmessage = async function(event) {
// get encoded codec2 bytes from websocket message
const encoded = await event.data.arrayBuffer();
// decode codec2 audio
const decoded = await Codec2Lib.runDecode(codecModeElement.value, encoded);
// convert decoded codec2 to wav audio
const wavAudio = await Codec2Lib.rawToWav(decoded);
// play wav audio buffer
let audioCtx = new AudioContext()
const audioBuffer = await audioCtx.decodeAudioData(wavAudio.buffer);
const sampleSource = audioCtx.createBufferSource();
sampleSource.buffer = audioBuffer;
sampleSource.connect(audioCtx.destination)
sampleSource.start(0);
};
}
function stopListening() {
if(listenWebsocket){
listenWebsocket.close();
listenWebsocket = null;
}
}
function encodeWAV(samples, sampleRate = 8000, numChannels = 1) {
const buffer = new ArrayBuffer(44 + samples.length * 2);
const view = new DataView(buffer);
// RIFF chunk descriptor
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + samples.length * 2, true); // file length
writeString(view, 8, 'WAVE');
// fmt sub-chunk
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true); // sub-chunk size
view.setUint16(20, 1, true); // audio format (1 = PCM)
view.setUint16(22, numChannels, true); // number of channels
view.setUint32(24, sampleRate, true); // sample rate
view.setUint32(28, sampleRate * numChannels * 2, true); // byte rate
view.setUint16(32, numChannels * 2, true); // block align
view.setUint16(34, 16, true); // bits per sample
// data sub-chunk
writeString(view, 36, 'data');
view.setUint32(40, samples.length * 2, true); // data chunk length
// write the PCM samples
floatTo16BitPCM(view, 44, samples);
return buffer;
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
function floatTo16BitPCM(output, offset, input) {
for (let i = 0; i < input.length; i++, offset += 2) {
const s = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
}
}
function formatBytes(bytes) {
if(bytes === 0){
return '0 Bytes';
}
const k = 1024;
const decimals = 0;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + ' ' + sizes[i];
}
</script>
</body>
</html>

135
web.py
View File

@@ -146,6 +146,141 @@ class ReticulumWebChat:
return websocket_response
# handle websocket clients for initiating a call
@routes.get("/call/initiate/{destination_hash}")
async def ws(request):
# get path params
destination_hash = request.match_info.get("destination_hash", "")
# convert destination hash to bytes
destination_hash = bytes.fromhex(destination_hash)
# prepare websocket response
websocket_response = web.WebSocketResponse()
await websocket_response.prepare(request)
# wait until we have a path to the destination
if not RNS.Transport.has_path(destination_hash):
print("waiting for path to server")
RNS.Transport.request_path(destination_hash)
while not RNS.Transport.has_path(destination_hash):
await asyncio.sleep(0.1)
# connect to server
print("establishing link with server")
server_identity = RNS.Identity.recall(destination_hash)
server_destination = RNS.Destination(
server_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
"call",
"audio"
)
# todo implement
def link_established(link):
print("link established")
# todo implement
def link_closed(link):
if link.teardown_reason == RNS.Link.TIMEOUT:
print("The link timed out, exiting now")
elif link.teardown_reason == RNS.Link.DESTINATION_CLOSED:
print("The link was closed by the server, exiting now")
else:
print("Link closed")
# todo implement
def client_packet_received(message, packet):
# todo, we don't send anything from the call initiator from the call receiver yet...
pass
# create link
link = RNS.Link(server_destination)
# register link state callbacks
link.set_packet_callback(client_packet_received)
link.set_link_established_callback(link_established)
link.set_link_closed_callback(link_closed)
# handle websocket messages until disconnected
async for msg in websocket_response:
msg: WSMessage = msg
if msg.type == WSMsgType.BINARY:
try:
# drop audio packet if it is too big to send
if len(msg.data) > RNS.Link.MDU:
print("dropping packet " + str(len(msg.data)) + " bytes exceeds the link packet MDU of " + str(RNS.Link.MDU) + " bytes")
continue
# send codec2 audio received from call initiator on websocket, to call receiver over reticulum link
print("sending bytes to call receiver: {}".format(len(msg.data)))
RNS.Packet(link, msg.data).send()
except Exception as e:
# ignore errors while handling message
print("failed to process client message")
print(e)
elif msg.type == WSMsgType.ERROR:
# ignore errors while handling message
print('ws connection error %s' % websocket_response.exception())
return websocket_response
# handle websocket clients for listening for calls
@routes.get("/call/listen")
async def ws(request):
# prepare websocket response
websocket_response = web.WebSocketResponse()
await websocket_response.prepare(request)
# create destination to allow incoming audio calls
server_identity = self.identity
server_destination = RNS.Destination(
server_identity,
RNS.Destination.IN,
RNS.Destination.SINGLE,
"call",
"audio",
)
# client connected to us
def client_connected(link):
print("client connected")
link.set_link_closed_callback(client_disconnected)
link.set_packet_callback(server_packet_received)
# client disconnected from us
def client_disconnected(link):
print("client disconnected")
# client sent us a packet
def server_packet_received(message, packet):
# send audio received from call initiator to call receiver websocket
asyncio.run(websocket_response.send_bytes(message))
# todo send our audio back to call initiator
# register link state callbacks
server_destination.set_link_established_callback(client_connected)
# announce our call.audio destination
print("call.audio announced and waiting for connection: "+ RNS.prettyhexrep(server_destination.hash))
server_destination.announce()
# handle websocket messages until disconnected
async for msg in websocket_response:
msg: WSMessage = msg
if msg.type == WSMsgType.ERROR:
# ignore errors while handling message
print('ws connection error %s' % websocket_response.exception())
return websocket_response
# serve announces
@routes.get("/api/v1/announces")
async def index(request):