diff options
-rw-r--r-- | .vscode/launch.json | 13 | ||||
-rw-r--r-- | README.md | 48 | ||||
-rwxr-xr-x | doc/img/sim7600-playback.720p.30fps.mp4 | bin | 0 -> 827875 bytes | |||
-rw-r--r-- | doc/mmfwd.sample.yaml | 8 | ||||
-rw-r--r-- | src/mmfwd-callam.cpp | 154 | ||||
-rw-r--r-- | src/mmfwd/__init__.py | 18 | ||||
-rw-r--r-- | src/mmfwd/recsync/__init__.py | 125 | ||||
-rw-r--r-- | src/mmfwd/recsync/__main__.py | 9 | ||||
-rw-r--r-- | src/mmfwd/recsync/exceptions.py | 2 |
9 files changed, 351 insertions, 26 deletions
diff --git a/.vscode/launch.json b/.vscode/launch.json index 1a65bdf..04398d4 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,11 +5,22 @@ "version": "0.2.0", "configurations": [ { - "name": "Python Debugger: Module", + "name": "Python Debugger: Module mmfwd", "type": "debugpy", "request": "launch", "module": "mmfwd", "cwd": "${workspaceFolder}/src", + }, + { + "name": "Python Debugger: Module mmfwd.recsync", + "type": "debugpy", + "request": "launch", + "module": "mmfwd.recsync", + "cwd": "${workspaceFolder}/src", + "env": { + // your s3 prefix here, with or without trailing slash + "MMFWD_RECSYNC_S3_UPLOAD_PREFIX": "s3://.../mmfwd/rec/" + } } ] } @@ -95,6 +95,54 @@ To encode the recorded raw audio files: ffmpeg -ar 16000 -ac 1 -f s16le -i PCM_FILE -c:a libopus OUTFILE.ogg ``` +## Local playback +Config to enable. `1` for incoming audio only, `2` for outgoing audio only, `3` +for both. `0` to disable: + +```yaml +mmfwd: + instances: + - +... + call-am: +... + playback: 1 +... +``` + +### Enable ringtone (optional) +https://github.com/user-attachments/assets/209f5bdd-2e63-4373-81d2-5a47c403db82 + +<video controls src="doc/img/sim7600-playback.720p.30fps.mp4" title="sim7600-playback.720p.30fps.mp4"></video> + +Make a simple ringtone: + +```sh +# short bursts of alternating 250 and 500 Hz sine waves +truncate -s 0 bell.pcm +for (( i = 0; i < 6; i += 1 )) +do + ffmpeg -loglevel error -f lavfi -i "sine=frequency=250:duration=0.1" -f s16le -ar 16000 -ac 1 - >> bell.pcm + ffmpeg -loglevel error -f lavfi -i "sine=frequency=500:duration=0.2" -f s16le -ar 16000 -ac 1 - >> bell.pcm +done + +# encode it to opus +ffmpeg -loglevel error -f s16le -ar 16000 -ac 1 -i bell.pcm -c:a libopus ringtone.ogg +``` + +Config to enable ringtone: + +```yaml + ringtone-exec: + - ffplay + - -loglevel + - error + - -autoexit + - -nodisp + - ringtone.ogg +... +``` + ## MM Patches - https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/merge_requests/1293 - https://gitlab.freedesktop.org/mobile-broadband/ModemManager/-/issues/996 diff --git a/doc/img/sim7600-playback.720p.30fps.mp4 b/doc/img/sim7600-playback.720p.30fps.mp4 Binary files differnew file mode 100755 index 0000000..7228f0b --- /dev/null +++ b/doc/img/sim7600-playback.720p.30fps.mp4 diff --git a/doc/mmfwd.sample.yaml b/doc/mmfwd.sample.yaml index 0d4396a..f0e9d91 100644 --- a/doc/mmfwd.sample.yaml +++ b/doc/mmfwd.sample.yaml @@ -12,3 +12,11 @@ mmfwd: call-am: enabled: false exec: "/usr/local/mmfwd-callam" + playback: 0 + ringtone-exec: + - ffplay + - -loglevel + - error + - -autoexit + - -nodisp + - ringtone.ogg diff --git a/src/mmfwd-callam.cpp b/src/mmfwd-callam.cpp index 90ad4d2..3104dd7 100644 --- a/src/mmfwd-callam.cpp +++ b/src/mmfwd-callam.cpp @@ -31,10 +31,7 @@ struct { const char *hello_sample_pat; // glob patterns const char *outfile_prefix; struct { - const char *sample_rate; - const char *channels; - const char *fmt; - bool playback; // TODO + bool enabled[2]; } playback; int vl; } params; @@ -66,9 +63,9 @@ size_t getrndsize (void) { void init_params (void) { params.hello_sample_pat = "hello/*.pcm"; - params.playback.sample_rate = "8000"; - params.playback.channels = "1"; - params.playback.fmt = "S16_LE"; + // params.playback.sample_rate = "8000"; + // params.playback.channels = "1"; + // params.playback.fmt = "S16_LE"; } void init_g (void) { @@ -77,6 +74,108 @@ void init_g (void) { g.playback[0] = g.playback[1] = -1; } +void parse_env (void) { + const char *env; + + env = getenv("MMFWD_CALLAM_PLAYBACK"); + if (env != NULL) { + int v = 0; + + sscanf(env, "%d", &v); + params.playback.enabled[0] = v & 1 ? true : false; + params.playback.enabled[1] = v & 2 ? true : false; + } +} + +pid_t do_playback_exec (const int p_stdin, const int p_stdout) { + pid_t pid; + int fr; + + pid = fork(); + if (pid < 0) { + return -1; + } + else if (pid > 0) { + return pid; + } + + close(STDIN_FILENO); + close(STDOUT_FILENO); + dup2(p_stdin, STDIN_FILENO); + dup2(p_stdout, STDOUT_FILENO); + close(p_stdin); + close(p_stdout); + + fr = system( + "ffmpeg -loglevel error -f s16le -ar 16000 -ac 1 -i pipe: -filter:a loudnorm -ar 16000 -ac 1 -f s16le - | " + "aplay -q -r 16000 -c 1 -f S16_LE -" + ); + if (fr < 0) { + perror(ARGV0 ": system()"); + abort(); + } + exit(fr); + + assert("unreachable" && false); +} + +bool open_playback (void) { + int p[2]; + int fr; + pid_t pid[2] = { -1, -1 }; + int fd_in[2] = { -1, -1 }; + int fd_out[2] = { -1, -1 }; + int blackhole; + + fr = pipe(p); + if (fr < 0) { + perror(ARGV0 ": pipe()"); + goto ERR; + } + close(p[0]); + blackhole = p[1]; + + for (size_t i = 0; i < 2; i += 1) { + if (!params.playback.enabled[i]) { + continue; + } + + fr = pipe(p); + if (fr < 0) { + perror(ARGV0 ": pipe()"); + goto ERR; + } + fd_in[i] = p[0]; + fd_out[i] = p[1]; + + pid[i] = do_playback_exec(p[0], blackhole); + if (pid[i] < 0) { + goto ERR; + } + + g.playback[i] = p[1]; + close(fd_in[i]); + fd_in[i] = -1; + + // Let audio skip when the buffer gets full. + // Should be fine as long as the size of the buffer is aligned to 2 byte + // boundary which is always the case. + fr = fcntl(g.playback[i], F_GETFL); + fr |= O_NONBLOCK; + fr = fcntl(g.playback[i], F_SETFL); + assert(fr == 0); + } + + return true; +ERR: // house-keeping + for (size_t i = 0; i < 2; i += 1) { + close(fd_in[i]); + close(fd_out[i]); + kill(pid[i], SIGHUP); + } + return false; +} + bool open_dev (void) { struct termios tis; const int fd = open(params.dev, O_RDWR | O_NONBLOCK); @@ -209,6 +308,23 @@ bool write_full (const int fd, const void *m, size_t len, const std::string &fn) return true; } +void sink_playback_audio (int *fd, const void *buf, const size_t len) { + ssize_t iofr; + + if (*fd < 0) { + return; + } + + iofr = write(*fd, buf, len); + // Errors are not fatal: + // - skip some samples when the child process struggle/stopped + // - EPIPE in case of death of child process + if (iofr < 0 && errno == EPIPE) { + close(*fd); + *fd = -1; + } +} + bool loop_main (void) { uint8_t buf[4096]; size_t sample_pos = 0; @@ -259,14 +375,7 @@ bool loop_main (void) { if (!write_full(g.fd[0], buf, blen, g.outfile[0])) { return false; } - - if (g.playback[0] >= 0) { - iofr = write(g.playback[0], buf, blen); - if (iofr < 0) { - close(g.playback[0]); - g.playback[0] = -1; - } - } + sink_playback_audio(&g.playback[0], buf, blen); } if (pfd.events & POLLOUT) { @@ -294,14 +403,7 @@ bool loop_main (void) { if (!write_full(g.fd[1], ptr, blen, g.outfile[1])) { return false; } - - if (g.playback[1] >= 0) { - iofr = write(g.playback[1], ptr, blen); - if (iofr < 0) { - close(g.playback[1]); - g.playback[1] = -1; - } - } + sink_playback_audio(&g.playback[1], buf, blen); sample_pos += blen; if (sample_pos >= g.hsample.len) { @@ -328,6 +430,8 @@ void handle_termsig (int) { int main (const int argc, const char **argv) { init_params(); init_g(); + + parse_env(); // TODO: getopt() if (argc <= 2) { @@ -340,7 +444,9 @@ int main (const int argc, const char **argv) { params.dev = argv[1]; params.outfile_prefix = argv[2]; - if (!open_dev() || !open_hsample() || !open_fds()) { + signal(SIGPIPE, SIG_IGN); + + if (!open_playback() || !open_dev() || !open_hsample() || !open_fds()) { return 1; } diff --git a/src/mmfwd/__init__.py b/src/mmfwd/__init__.py index 17d1485..2c34d99 100644 --- a/src/mmfwd/__init__.py +++ b/src/mmfwd/__init__.py @@ -70,6 +70,7 @@ class Instance: 'enabled': False }) self.callam_proc = None + self.callam_ringtone_proc = None self.callam_timer = None def match (self, m) -> bool: @@ -325,6 +326,11 @@ class Application: else: call.hangup(None, self.on_call_hangup, ud) + if ud.instance.callam_ringtone_proc is not None: + ud.instance.callam_ringtone_proc.terminate() + ud.instance.callam_ringtone_proc.wait() + ud.instance.callam_ringtone_proc = None + if ud.instance.callam_proc is not None: ud.instance.callam_proc.terminate() ud.instance.callam_proc.wait() @@ -359,6 +365,13 @@ class Application: print("on_call_accept()") # FIXME call.connect('state-changed', self.on_call_change, ud) + if ud.instance.callam.get('ringtone-exec'): + try: + ud.instance.callam_ringtone_proc = subprocess.Popen( + ud.instance.callam['ringtone-exec']) + except Exception as e: + sys.stderr.write(e + os.linesep) + # The custom ModemManager will send AT+CPCMREG. # mmfwd-callam process will set up the serial, play the hello message # and record @@ -373,8 +386,11 @@ class Application: os.makedirs(dir, exist_ok = True) + env = os.environ.copy() + env['MMFWD_CALLAM_PLAYBACK'] = str(ud.instance.callam['playback']) + exec = [ ud.instance.callam['exec'], "/dev/" + ud.audio_port, path ] - ud.instance.callam_proc = subprocess.Popen(exec) + ud.instance.callam_proc = subprocess.Popen(exec, env = env) # 5 minutes timeout ud.instance.callam_timer = GLib.timeout_add_seconds( 60 * 4, diff --git a/src/mmfwd/recsync/__init__.py b/src/mmfwd/recsync/__init__.py new file mode 100644 index 0000000..32d0fc1 --- /dev/null +++ b/src/mmfwd/recsync/__init__.py @@ -0,0 +1,125 @@ +import datetime +from fcntl import LOCK_EX, flock +import glob +from io import SEEK_SET +import os +import subprocess +import sys +from typing import assert_never +from .exceptions import * + +def acquire_plock (): + f = open("mmfwd-recsync.pid", "w+") + try: + flock(f, LOCK_EX) + f.write(str(os.getpid())) + f.flush() + + return f + except OSError as e: + f.seek(0, SEEK_SET) + pid = f.read(255).strip() + f.close() + + raise MMFWDRSProcessLockException( + "Process lock failed (Another process: %s)" % (pid), + e) + + assert_never() + +def strip_fileext (x: str) -> str: + last_dot = x.rfind('.') + last_sep = x.rfind(os.path.sep) + + # the last . is after the last / + # or there's no /, but dot exists + if last_dot >= 0 and last_sep < last_dot: + # then, it's safe to strip + return x[:last_dot] + # there's no . + return x + + +def proc_dir (dir: str): + # including trailing '/' if desired + s3_upload_prefix = os.getenv('MMFWD_RECSYNC_S3_UPLOAD_PREFIX') + # minimum audio length: 5 seconds + minsize = 5 * 1 * 2 * 16000 # seconds * nb_channels * bytes_per_sample * rate + + for path in glob.glob(dir + "/*.pcm"): # for each raw audio file, + if not os.path.isfile(path): + continue + + # do stat() + stat = os.stat(path) + + # delete short recordings + if stat.st_size < minsize: + sys.stderr.write( + "mmfwd.recsync: deleting short recording: " + + path + + os.linesep) + os.remove(path) + continue + + # ignore if less than an hour as a security measure against KYC attempts + now = datetime.datetime.now(datetime.UTC) + f_mtime = datetime.datetime.fromtimestamp(stat.st_mtime, datetime.UTC) + if now - f_mtime < datetime.timedelta(hours = 1.0): + continue + + # be defensive: align the original raw audio file to 2 bytes boundary + # so that ffmpeg won't complain + fsize = stat.st_size + if fsize % 2 != 0: + fsize = (fsize // 2) * 2 + os.truncate(path, fsize) + + # sep file ext, fabricate output file names + basename = strip_fileext(os.path.basename(path)) + s3_basename = "%d-%02d/%s" % (f_mtime.year, f_mtime.month, basename) + local_basepath = os.path.dirname(path) + '/' + basename + s3_basepath = s3_upload_prefix + s3_basename + local_outf_flac = local_basepath + ".flac" + local_outf_ogg = local_basepath + ".ogg" + s3_outf_flac = s3_basepath + ".flac" + s3_outf_ogg = s3_basepath + ".ogg" + + # run ffmpeg for FLAC, lossless original not post processed + subprocess.run([ + "ffmpeg", "-nostdin", "-loglevel", "error", + "-f", "s16le", "-ac", "1", "-ar", "16000", + "-i", path, + "-y", + local_outf_flac + ], check = True) + # run ffmpeg for opus OGG w/ RMS normalisation + subprocess.run([ + "ffmpeg", "-nostdin", "-loglevel", "error", + "-f", "s16le", "-ac", "1", "-ar", "16000", + "-i", path, + "-filter:a", "loudnorm", + "-c:a", "libopus", + "-y", + local_outf_ogg + ], check = True) + + # do upload + subprocess.run([ + "aws", "s3", "cp", "--no-progress", local_outf_flac, s3_outf_flac + ], check = True) + subprocess.run([ + "aws", "s3", "cp", "--no-progress", local_outf_ogg, s3_outf_ogg + ], check = True) + + # good! now delete files including the original from local + os.remove(local_outf_flac) + os.remove(local_outf_ogg) + os.remove(path) + + # if the directory becomes empty + dir_not_empty = True + for _ in os.scandir(dir): + dir_not_empty = False + break + if dir_not_empty: os.rmdir(dir) diff --git a/src/mmfwd/recsync/__main__.py b/src/mmfwd/recsync/__main__.py new file mode 100644 index 0000000..6f56adf --- /dev/null +++ b/src/mmfwd/recsync/__main__.py @@ -0,0 +1,9 @@ +import glob +import os +from . import * + +with acquire_plock(): # flock on pid file + for path in glob.glob("rec/????-??"): # for each YYYY-MM dir, + if not os.path.isdir(path): + continue + proc_dir(path) diff --git a/src/mmfwd/recsync/exceptions.py b/src/mmfwd/recsync/exceptions.py new file mode 100644 index 0000000..052c48d --- /dev/null +++ b/src/mmfwd/recsync/exceptions.py @@ -0,0 +1,2 @@ +class MMFWDRSException (BaseException): ... +class MMFWDRSProcessLockException (MMFWDRSException): ... |