Skip to content

Commit d8a77f6

Browse files
committed
Socket.IO streaming works, and fails back if it doesn't
1 parent 3dc0869 commit d8a77f6

File tree

4 files changed

+247
-99
lines changed

4 files changed

+247
-99
lines changed

README.md

+3-2
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ for comments and code.
1515
MIT license
1616

1717
# TODO
18-
- streaming upload with WebSockets (in progress)
18+
- link, size, and duration on the playback page (in progress)
1919
- update screenshot and cover image with the noise supression checkbox
20-
- maybe companding with sox?
2120
- production WSGI server? Replit deployments might not be cross-platform
21+
- maybe companding with sox?
22+
- handle 2 channel stereo?

main.py

+104-42
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@
99
# github: https://github.com/jsalsman/webrec
1010

1111
from flask import Flask, request, render_template, redirect, send_from_directory
12-
from flask_socketio import SocketIO
12+
from flask_socketio import SocketIO, emit # Fails over to POST submissions
1313
import sox # needs command line sox and the pysox package
14+
import lameenc # to produce .mp3 files for playback
1415
from datetime import datetime # for audio file timestamps
1516
import os # to delete old audio files
1617
from time import time # to keep files less than 10 minutes old
1718

18-
from sys import stderr # best for Replit; you may want to import logging
19+
from sys import stderr # best for Replit; you may want to 'import logging'
1920
log = lambda message: stderr.write(message + '\n') # ...and connect this
2021

2122
app = Flask(__name__)
22-
socketio = SocketIO(app)
23+
socketio = SocketIO(app) # Websocket
2324

2425
@app.route('/') # redirect from / to /record
2526
def index():
@@ -40,48 +41,73 @@ def upload_audio():
4041

4142
timestamp = datetime.now().strftime("%M%S%f")[:8] # MMSSssss
4243
raw_filename = f"audio-{timestamp}.raw"
43-
wav_filename = f"audio-{timestamp}.wav"
4444

4545
audio_file.save('static/' + raw_filename)
4646

47-
# Convert format, trim silence
48-
tfm = sox.Transformer()
49-
tfm.set_input_format(file_type='raw', rate=16000, bits=16,
50-
channels=1, encoding='signed-integer')
51-
52-
tfm.silence(min_silence_duration=0.25, # remove lengthy silence
53-
buffer_around_silence=True) # replace removals with 1/4 second
54-
# https://pysox.readthedocs.io/en/latest/api.html#sox.transform.Transformer.silence
55-
56-
tfm.build('static/' + raw_filename, 'static/' + wav_filename)
57-
duration = sox.file_info.duration('static/' + wav_filename)
58-
59-
# Clean up older files; maximum 40 MB will remain
60-
files = [os.path.join('static', f) for f in
61-
os.listdir('static') if f.startswith('audio-')]
62-
# Sort files by last modified time, oldest first
63-
files.sort(key=lambda x: os.path.getmtime(x))
64-
current_time = time()
65-
# Remove all but the 10 most recent audio files
66-
for file in files[:-10]:
67-
# Get the modification time of the file
68-
mod_time = os.path.getmtime(file)
69-
# Calculate the age of the file in seconds
70-
file_age = current_time - mod_time
71-
# Check if the file is older than 10 minutes
72-
if file_age > 600:
73-
os.remove(file)
74-
audio_space = sum([os.path.getsize('static/' + f)
75-
for f in os.listdir('static')
76-
if f.startswith('audio-')]) / (1024 ** 2)
77-
78-
log(f'Built {wav_filename} ({duration:.1f} seconds.) ' +
79-
f'All audio using {audio_space:.2f} MB.')
80-
81-
return redirect(f'/playback/{wav_filename}')
47+
return redirect(f'/playback/' + process_file(raw_filename))
8248

8349
return "No audio file", 400
8450

51+
def process_file(raw_filename):
52+
# Convert format, trim silence
53+
tfm = sox.Transformer()
54+
tfm.set_input_format(file_type='raw', rate=16000, bits=16,
55+
channels=1, encoding='signed-integer')
56+
57+
tfm.silence(min_silence_duration=0.25, # remove lengthy silence
58+
buffer_around_silence=True) # replace removals with 1/4 second
59+
# https://pysox.readthedocs.io/en/latest/api.html#sox.transform.Transformer.silence
60+
61+
#pcm = tfm.build_array('static/' + raw_filename) # FAILS
62+
# sox/transform.py", line 793, in build_array
63+
# encoding_out = [
64+
# IndexError: list index out of range
65+
66+
tfm.build('static/' + raw_filename, 'static/tmp-' + raw_filename)
67+
68+
# Set up the MP3 encoder
69+
encoder = lameenc.Encoder()
70+
encoder.set_in_sample_rate(16000)
71+
encoder.set_channels(1)
72+
encoder.set_bit_rate(64) # https://github.com/chrisstaite/lameenc/blob/main/lameenc.c
73+
encoder.set_quality(2) # https://github.com/gypified/libmp3lame/blob/master/include/lame.h
74+
75+
# Encode the PCM data to MP3
76+
with open('static/tmp-' + raw_filename, 'rb') as f:
77+
mp3_data = encoder.encode(f.read())
78+
mp3_data += encoder.flush()
79+
os.remove('static/tmp-' + raw_filename)
80+
81+
mp3_fn = raw_filename.replace('.raw', '.mp3')
82+
83+
with open('static/' + mp3_fn, 'wb') as f:
84+
f.write(mp3_data)
85+
86+
duration = sox.file_info.duration('static/' + mp3_fn)
87+
88+
# Clean up older files; maximum 40 MB will remain
89+
files = [os.path.join('static', f) for f in
90+
os.listdir('static') if f.startswith('audio-')]
91+
# Sort files by last modified time, oldest first
92+
files.sort(key=lambda x: os.path.getmtime(x))
93+
current_time = time()
94+
# Remove all but the 10 most recent audio files
95+
for file in files[:-10]:
96+
# Get the modification time of the file
97+
mod_time = os.path.getmtime(file)
98+
# Calculate the age of the file in seconds
99+
file_age = current_time - mod_time
100+
# Check if the file is older than 10 minutes
101+
if file_age > 600:
102+
os.remove(file)
103+
audio_space = sum([os.path.getsize('static/' + f)
104+
for f in os.listdir('static')
105+
if f.startswith('audio-')]) / (1024 ** 2)
106+
107+
log(f'Built {mp3_fn} ({duration:.1f} seconds.) ' +
108+
f'All audio using {audio_space:.2f} MB.')
109+
return mp3_fn
110+
85111
@app.route('/playback/<filename>')
86112
def playback(filename):
87113
return render_template('playback.html', audio=filename)
@@ -98,7 +124,43 @@ def send_js(path):
98124
for file in [os.path.join('static', f) for f in os.listdir('static')
99125
if f.startswith('audio-')]:
100126
os.remove(file)
101-
102-
app.run(host='0.0.0.0', port=81)
103-
# TODO: production WSGI server
127+
128+
# WebSocket implementation
129+
active_streams = {}
130+
sid_to_filename = {}
131+
132+
@socketio.on('connect')
133+
def websocket_connect():
134+
timestamp = datetime.now().strftime("%H%M%S%f")[:8]
135+
sid_to_filename[request.sid] = f"audio-{timestamp}.raw"
136+
137+
@socketio.on('audio_chunk')
138+
def websocket_chunk(data):
139+
try:
140+
if request.sid not in active_streams:
141+
filename = sid_to_filename[request.sid]
142+
active_streams[request.sid] = open(f'static/{filename}', 'wb')
143+
active_streams[request.sid].write(data)
144+
except Exception as e:
145+
log(f"Error writing audio data: {e}")
146+
return 'fail', repr(e)
147+
148+
@socketio.on('end_recording')
149+
def websocket_end():
150+
try:
151+
if request.sid in active_streams:
152+
active_streams[request.sid].close()
153+
filename = sid_to_filename[request.sid]
154+
mp3_fn = process_file(filename) # See above
155+
del active_streams[request.sid]
156+
del sid_to_filename[request.sid]
157+
return '/playback/' + mp3_fn
158+
except Exception as e:
159+
log(f"Error ending websocket: {e}")
160+
return 'fail', repr(e)
161+
162+
#app.run(host='0.0.0.0', port=81)
163+
socketio.run(app, host='0.0.0.0', port=81)
164+
165+
# TODO? production WSGI server
104166
# see https://replit.com/talk/learn/How-to-set-up-production-environment-for-your-Flask-project-on-Replit/139169

static/recording-processor.js

+19-13
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
// origin: https://googlechromelabs.github.io/web-audio-samples/audio-worklet/migration/worklet-recorder/
2-
1+
// adapted from:
2+
// https://googlechromelabs.github.io/web-audio-samples/audio-worklet/migration/worklet-recorder/
3+
//
34
// Copyright (c) 2022 The Chromium Authors. All rights reserved.
45
// Use of this source code is governed by a BSD-style license that can be
56
// found in the LICENSE file.
@@ -25,15 +26,16 @@ class RecordingProcessor extends AudioWorkletProcessor {
2526
}
2627

2728
// Initialize _recordingBuffer as a Uint8Array
28-
this._recordingBuffer = new Uint8Array(this.maxRecordingFrames * 2);
29+
this.recordingBuffer = new Uint8Array(this.maxRecordingFrames * 2);
2930

3031
this.recordedFrames = 0;
3132
this.isRecording = false;
3233
this.lastSentFrame = 0;
3334

3435
// We will use a timer to gate our messages; this one will publish at 30hz
35-
this.framesSinceLastPublish = 0;
3636
this.publishInterval = this.sampleRate / 30;
37+
this.framesSinceLastPublish = 0;
38+
this.bufferSlice = null; // streaming slices
3739

3840
// We will keep a live sum for rendering the visualizer.
3941
this.sampleSum = 0;
@@ -43,20 +45,23 @@ class RecordingProcessor extends AudioWorkletProcessor {
4345
this.isRecording = event.data.setRecording;
4446

4547
if (this.isRecording === false) {
48+
this.bufferSlice = this.recordingBuffer.slice(
49+
this.lastSentFrame * 2, this.recordedFrames * 2);
4650
this.port.postMessage({
4751
message: 'SHARE_RECORDING_BUFFER',
48-
buffer: this._recordingBuffer,
49-
recordingLength: this.recordedFrames, // ADDED
52+
buffer: this.recordingBuffer,
53+
recordingLength: this.recordedFrames,
54+
bufferSlice: this.bufferSlice,
5055
});
5156
} else {
52-
this.recordedFrames = 0; // RESET ON START to handle multiple sessions ADDED
57+
this.recordedFrames = 0; // RESET ON START to handle multiple sessions
5358
}
5459
}
5560
};
5661
}
5762

5863
process(inputs, outputs, params) {
59-
// Assuming we are only interested in the first channel 0 // TODO: convert to mono properly
64+
// Assuming we are only interested in the first channel 0 // TODO? convert to mono properly
6065
let inputBuffer = inputs[0][0];
6166
for (let sample = 0; sample < inputBuffer.length; ++sample) {
6267
let currentSample = inputBuffer[sample];
@@ -66,8 +71,8 @@ class RecordingProcessor extends AudioWorkletProcessor {
6671
let signed16bits = Math.max(-32768,
6772
Math.min(32767, currentSample * 32768.0));
6873
let index = (sample + this.recordedFrames) * 2;
69-
this._recordingBuffer[index] = signed16bits & 255; // low byte, little endian
70-
this._recordingBuffer[index + 1] = (signed16bits >> 8) & 255; // high
74+
this.recordingBuffer[index] = signed16bits & 255; // low byte, little endian
75+
this.recordingBuffer[index + 1] = (signed16bits >> 8) & 255; // high
7176
}
7277

7378
// Sum values for visualizer
@@ -83,13 +88,13 @@ class RecordingProcessor extends AudioWorkletProcessor {
8388

8489
// Post a recording recording length update on the clock's schedule
8590
if (shouldPublish) {
86-
let bufferSlice = this._recordingBuffer.slice(
91+
this.bufferSlice = this.recordingBuffer.slice(
8792
this.lastSentFrame * 2, this.recordedFrames * 2);
8893

8994
this.port.postMessage({
9095
message: 'UPDATE_RECORDING',
9196
recordingLength: this.recordedFrames,
92-
bufferSlice: bufferSlice,
97+
bufferSlice: this.bufferSlice,
9398
});
9499

95100
this.lastSentFrame = this.recordedFrames;
@@ -99,7 +104,8 @@ class RecordingProcessor extends AudioWorkletProcessor {
99104
this.isRecording = false;
100105
this.port.postMessage({
101106
message: 'MAX_RECORDING_LENGTH_REACHED',
102-
buffer: this._recordingBuffer,
107+
buffer: this.recordingBuffer,
108+
bufferSlice: this.bufferSlice,
103109
});
104110

105111
this.recordedFrames += 128;

0 commit comments

Comments
 (0)