Difference between revisions of "Commercial detection with silences"

From MythTV Official Wiki
Jump to: navigation, search
(Updated cluster version to v2)
m (silence.py: URL corrected.)
 
(41 intermediate revisions by 7 users not shown)
Line 1: Line 1:
 
{{Script info
 
{{Script info
 
|author=Hippo
 
|author=Hippo
|short=A replacement for mythcommflag that works by detecting short silent periods around commercials.
+
|short=An alternative to mythcommflag that works by detecting short silent periods around commercials.
|long=A python program based on [[Mythcommflag-wrapper]] (thank you Cowbut) that can be used on UK FreeviewHD channels and probably others.
+
|long=A python/C++ program based on [[Mythcommflag-wrapper]] (thank you Cowbut) that works by detecting short silent periods around commercials.  
 
|category=Scripts
 
|category=Scripts
|S25=yes|S26=yes}}
+
|S25=yes|S26=yes|S27=yes|S28=yes}}
  
== Original version ==
+
== Relevance ==
 +
* UK: Works well for Freeview/FreeSat SD/HD,
 +
* Australia: Works for Freeview SD/HD,
 +
* New Zealand: Works,
 +
* Germany: Works
 +
 
 +
== Initial Version (by Hippo) ==
 
I tried out the scripts in [[Mythcommflag-wrapper]] and they worked well on the Freeview channels I receive but not on the FreeviewHD channels. The reason is that the audio on FreevieHD is an AAC stream and not an MP3 stream. Fixing that would require decoding from AAC and encoding back to MP3 before letting the script analyse the MP3 stream. So I wrote a little C program to analyze an uncompressed audio stream and a Python program to wrap it up and turn the output into a commercial skip list.
 
I tried out the scripts in [[Mythcommflag-wrapper]] and they worked well on the Freeview channels I receive but not on the FreeviewHD channels. The reason is that the audio on FreevieHD is an AAC stream and not an MP3 stream. Fixing that would require decoding from AAC and encoding back to MP3 before letting the script analyse the MP3 stream. So I wrote a little C program to analyze an uncompressed audio stream and a Python program to wrap it up and turn the output into a commercial skip list.
  
 
To use this
 
To use this
* Compile the two C programs and put them somewhere the Python program can find it. (e.g. /usr/local/bin)
+
* Compile the C program and put it somewhere the Python program can find it. (e.g. /usr/local/bin)
 
* Copy the Python program to somehwere the backend can find it.
 
* Copy the Python program to somehwere the backend can find it.
* Follow the instructions on [[Mythcommflag-wrapper]] except the job setting should be 'mausc-wrapper.py %JOBID%'
+
* Follow the instructions on [[Mythcommflag-wrapper]] except the job setting should be 'silence.py %JOBID%'
  
 
The python program uses avconv to decode the program file to an AU stream. If you don't have avconv replace it with ffmpeg or mythffmpeg (avconv is the new name for ffmpeg). It upconverts the audio to 6 channels so that it works even when the audio switches around. If you know you only ever get stereo you can replace the 6 with 2 to save a bit of CPU power. It might have to go up in future. Up-converting is better because it's low power and always works whereas down-converting may fail depending on your version of avconv/ffmpeg.
 
The python program uses avconv to decode the program file to an AU stream. If you don't have avconv replace it with ffmpeg or mythffmpeg (avconv is the new name for ffmpeg). It upconverts the audio to 6 channels so that it works even when the audio switches around. If you know you only ever get stereo you can replace the 6 with 2 to save a bit of CPU power. It might have to go up in future. Up-converting is better because it's low power and always works whereas down-converting may fail depending on your version of avconv/ffmpeg.
Line 18: Line 24:
 
This can do near-realtime commflagging by enabling the backend setting to start commflagging when the recording starts. (mythtv-setup/General/Page9-JobQueueGlobal). The programs mark entries in the cutlist <max-break-setting> after the start of a break is detected so this will be after the commercial break has ended. If you are displaying the programme and get too close to the end you will be in the commercials before they are flagged. C'est la vie.
 
This can do near-realtime commflagging by enabling the backend setting to start commflagging when the recording starts. (mythtv-setup/General/Page9-JobQueueGlobal). The programs mark entries in the cutlist <max-break-setting> after the start of a break is detected so this will be after the commercial break has ended. If you are displaying the programme and get too close to the end you will be in the commercials before they are flagged. C'est la vie.
  
It's low CPU in that it only decodes the audio stream and since it follows the end of the recording it shouldn't thrash the memory or disk. avconv takes about 2% to decode ITV1-HD on a 1.6GHz Atom Asus motherboard. catagrower takes about 1% and could be a lot better if made less portable.
+
It's low CPU in that it only decodes the audio stream and since it follows the end of the recording it shouldn't thrash the memory or disk. avconv takes about 2% to decode ITV1-HD on a 1.6GHz Atom Asus motherboard.
{{Code box|catagrower.c|
 
<pre>
 
/* Copyright 2012 Crackers Phipps. */
 
/* Public domain. */
 
/* Compile with
 
  gcc -std=c99 -O catagrower.c -o catagrower
 
*/
 
/* This program will stop when the file has not grown for this many seconds. */
 
#define TIMEOUT 60
 
 
 
/* MythTV files are often large. */
 
#define _FILE_OFFSET_BITS 64
 
 
 
#include <stdio.h>
 
#include <stdlib.h>
 
#include <fcntl.h>
 
#include <unistd.h>
 
 
 
static void usage(const char *name) {
 
  fprintf(stderr, "Usage: %s <file>\n", name);
 
  fprintf(stderr, "<file>: file to be monitored.\n");
 
  fprintf(stderr, "The contents of the file will be copied to stdout.\n");
 
  fprintf(stderr, "Copying will stop when the file has stopped growing.\n");
 
}
 
 
 
int main(int argc, char **argv) {
 
 
 
  /* Check usage. */
 
  if (2 != argc) {
 
    usage(argv[0]);
 
    exit(1);
 
  }
 
 
 
  /* Load options. */
 
  int fd;
 
  if (-1 == (fd = open(argv[1], O_RDONLY))) {
 
    fprintf(stderr, "Could not open %s for reading.\n", argv[1]);
 
    usage(argv[0]);
 
    exit(2);
 
  }
 
 
 
#define BUFFSIZE 4096
 
  int timer = TIMEOUT;
 
  char buffer[BUFFSIZE];
 
  int bytes;
 
  while (timer > 0) {
 
    while (0 != (bytes = read(fd, buffer, BUFFSIZE))) {
 
      write (STDOUT_FILENO, buffer, bytes);
 
      timer = TIMEOUT;
 
    }
 
    sleep(1);
 
    timer--;
 
  }
 
  return 0;
 
}
 
</pre>
 
}}
 
 
 
{{Code box|mausc.c|
 
<pre>
 
/* Copyright 2013 Tinsel Phipps. */
 
/* Public domain. Links with libsndfile which is GPL. */
 
/* Compile with
 
  gcc -std=c99 -O mausc.c -o mausc -lsndfile -lm
 
  You may need the libsndfile-dev package installed.
 
*/
 
#include <stdlib.h>
 
#include <math.h>
 
#include <sndfile.h>
 
#include <errno.h>
 
#include <unistd.h>
 
#include <limits.h>
 
 
 
static void usage(const char *name) {
 
  fprintf(stderr, "Usage: %s <threshold> <min> <max> <rate>\n", name);
 
  fprintf(stderr, "<threshold>: silence threshold in dB.\n");
 
  fprintf(stderr, "<min>: minimum time for silence detection in seconds.\n");
 
  fprintf(stderr, "<max>: maximum length of breaks in seconds.\n");
 
  fprintf(stderr, "<rate>: frame rate of video.\n");
 
  fprintf(stderr, "An AU format file should be fed into this program.\n");
 
  fprintf(stderr, "Example: %s -70 0.15 400 25 < audio.au\n", name);
 
}
 
 
 
int main(int argc, char **argv) {
 
  
  /* Check usage. */
+
== Cluster Detecting Version (by dizygotheca)==
  if (5 != argc) {
 
    usage(argv[0]);
 
    exit(1);
 
  }
 
 
 
  /* Load options. */
 
  float threshold, min, max, rate;
 
  if (1 != sscanf(argv[1], "%f", &threshold)) {
 
    fprintf(stderr, "Could not parse threshold option into a number.\n");
 
    usage(argv[0]);
 
    exit(2);
 
  }
 
  if (1 != sscanf(argv[2], "%f", &min)) {
 
    fprintf(stderr, "Could not parse min option into a number.\n");
 
    usage(argv[0]);
 
    exit(2);
 
  }
 
  if (1 != sscanf(argv[3], "%f", &max)) {
 
    fprintf(stderr, "Could not parse max option into a number.\n");
 
    usage(argv[0]);
 
    exit(2);
 
  }
 
  if (1 != sscanf(argv[4], "%f", &rate)) {
 
    fprintf(stderr, "Could not parse rate option into a number.\n");
 
    usage(argv[0]);
 
    exit(2);
 
  }
 
 
 
  /* Scale threshold to integer range that libsndfile will use. */
 
  threshold = INT_MAX * pow(10, threshold / 20);
 
  /* Scale times to frames. */
 
  min = min * rate;
 
  max = max * rate;
 
 
 
  /* Check the input is an audiofile. */
 
  SNDFILE *input;
 
  SF_INFO metadata;
 
  input = sf_open_fd(STDIN_FILENO, SFM_READ, &metadata, SF_FALSE);
 
  if (NULL == input) {
 
    sf_perror(NULL);
 
    return sf_error(NULL);
 
  }
 
 
 
  /* Allocate data buffer to contain audio data from one video frame. */
 
  size_t frameSamples = metadata.channels * metadata.samplerate / rate;
 
 
 
  int *samples;
 
  samples = malloc(frameSamples * sizeof(int));
 
  if (NULL == samples) {
 
    perror(NULL);
 
    return errno;
 
  }
 
 
 
  /* Process the file one frame at a time and process cuts along the way. */
 
  int frames = 0;
 
  int silent = 0;
 
  int last_silent = 0;
 
  int gapend = 0;
 
  int gapstart = 0;
 
  int first_gapstart = 0;
 
  while (frameSamples == sf_read_int(input, samples, frameSamples)) {
 
    frames++;
 
    int maxabs = 0;
 
    for (unsigned i = 0; i < frameSamples; i++) {
 
      samples[i] = abs(samples[i]);
 
      maxabs = (maxabs > samples[i]) ? maxabs : samples[i];
 
    }
 
    last_silent = silent;
 
    silent = (maxabs < threshold);
 
    /* Remember first transition to silence. */
 
    if (silent && !gapstart) {
 
      gapstart = frames;
 
    }
 
    /* Store last transition out of silence. */
 
    if (!silent && last_silent) {
 
      /* Make sure it is long enough. */
 
      if (frames > gapstart + min) {
 
        gapend = frames;
 
        if (!first_gapstart) {
 
          first_gapstart = gapstart;
 
        }
 
      }
 
      gapstart = 0;
 
    }
 
    /* Create a skip when max frames have passed. */
 
    if (first_gapstart && gapend && frames > first_gapstart + max) {
 
      printf("%d %d\n", first_gapstart, gapend);
 
      fflush(stdout);
 
      gapstart = 0;
 
      gapend = 0;
 
      first_gapstart = 0;
 
    }
 
  }
 
  /* At end of file can have an unprocessed gap. */
 
  if (first_gapstart) {
 
    if (first_gapstart == gapstart) {
 
      gapend = frames;
 
    }
 
    printf("%d %d\n", first_gapstart, gapend);
 
  }
 
  return sf_close(input);
 
}
 
</pre>
 
}}
 
 
 
{{Code box|mausc-wrapper|
 
<pre>
 
#!/usr/bin/env python
 
# Build a skiplist from silence in the audio track.
 
# Based on http://www.mythtv.org/wiki/Transcode_wrapper_stub
 
from MythTV import MythDB, Job, Recorded, findfile, MythLog
 
from os import path
 
from subprocess import Popen, PIPE
 
from optparse import OptionParser
 
 
 
def runjob(jobid=None, chanid=None, starttime=None):
 
    # Tunable settings (would like to retrieve per channel from the database)
 
    thresh = -70 # Silence threshold in dB.
 
    minquiet = 0.15 # Minimum time for silence detection in seconds.
 
    maxbreak = 400 # Maximum length of adverts breaks.
 
    rate = 25 # Frame rate of video. (should be automatic)
 
 
 
    db = MythDB()
 
    if jobid:
 
        job = Job(jobid, db=db)
 
        chanid = job.chanid
 
        starttime = job.starttime
 
 
 
    try:
 
        rec = Recorded((chanid, starttime), db=db)
 
    except:
 
        if jobid:
 
            job.update({'status':job.ERRORED,
 
                        'comment':'ERROR: Could not find recording.'})
 
        else:
 
            print 'Could not find recording.'
 
        exit(1)
 
 
 
    # Get program handle in standard format.
 
    starttime = rec.starttime
 
    chanid = rec.chanid
 
 
 
    sg = findfile(rec.basename, rec.storagegroup, db=db)
 
    if sg is None:
 
        if jobid:
 
            job.update({'status':job.ERRORED,
 
                        'comment':'ERROR: Local access to recording not found.'})
 
        else:
 
            print 'Local access to recording not found.'
 
        exit(1)
 
 
 
    infile = path.join(sg.dirname, rec.basename)
 
 
 
    # Purge any existing skip list.
 
    rec.markup.clean()
 
    rec.commflagged = 0
 
    rec.update()
 
 
 
    # Write out the file contents and keep going till recording is finished.
 
    p1 = Popen(["catagrower", infile],
 
              stdout = PIPE)
 
    # Pipe through avconv to extract uncompressed audio stream.
 
    p2 = Popen(["avconv", "-v", "8", "-i", "pipe:0", "-f", "au", "-ac", "6", "-"],
 
              stdin = p1.stdout, stdout = PIPE)
 
    # Pipe to mausc which will spit out a list of breaks.
 
    p3 = Popen(["mausc", str(thresh), str(minquiet), str(maxbreak), str(rate)],
 
              stdin = p2.stdout, stdout = PIPE)
 
 
 
    # Store breaks in the database.
 
    breaks = 0
 
    while 1:
 
        line = p3.stdout.readline()
 
        if not line:
 
            break
 
        start, end = line.split()
 
        rec.markup.append(start, rec.markup.MARK_COMM_START, None)
 
        rec.markup.append(end, rec.markup.MARK_COMM_END, None)
 
        rec.commflagged = 1
 
        rec.update()
 
        breaks = breaks + 1
 
        if jobid is None:
 
            print 'Got a break at frame %s' % start
 
 
 
    if jobid:
 
        job.update({'status':272,
 
                    'comment':'Audio commflag detected %s breaks.' % breaks
 
                    })
 
    else:
 
        print 'Audio commflag detected %s breaks.' % breaks
 
 
 
def main():
 
    parser = OptionParser(usage="usage: %prog [options] [jobid]")
 
 
 
    parser.add_option('--chanid', action='store', type='int',
 
                      dest='chanid', help='Use chanid for manual operation')
 
    parser.add_option('--starttime', action='store', type='string',
 
                      dest='stime', help='Use starttime for manual operation')
 
    MythLog.loadOptParse(parser)
 
    opts, args = parser.parse_args()
 
 
 
    if len(args) == 1:
 
        runjob(jobid=args[0])
 
    elif opts.chanid and opts.stime:
 
        runjob(chanid=opts.chanid, starttime=opts.stime)
 
    else:
 
        print 'Script must be provided either jobid, or chanid and starttime.'
 
        parser.print_help()
 
        exit(1)
 
 
 
if __name__ == '__main__':
 
    main()
 
</pre>
 
}}
 
 
 
== Cluster Detecting Version ==
 
 
The basic silence detection algorithm is easily thrown by odd silences that occur within 6 mins of an advert and performs poorly on animations/kids programmes. I was keen to cut adverts out of my kids' shows so I developed an algorithm that detects clusters of silences: adverts are characterised by many silences close together whilst isolated silences within programmes are ignored.
 
The basic silence detection algorithm is easily thrown by odd silences that occur within 6 mins of an advert and performs poorly on animations/kids programmes. I was keen to cut adverts out of my kids' shows so I developed an algorithm that detects clusters of silences: adverts are characterised by many silences close together whilst isolated silences within programmes are ignored.
  
Line 336: Line 43:
  
 
=== Change Summary ===
 
=== Change Summary ===
*catagrower.cpp has minor mods to take its timeout from an arg. This is to allow the script to be run manually from the command line.
 
 
*silence.cpp replaces mausc.c. New algorithm. Optionally uses Qt/Myth libs in order to send messages to mythplayer.
 
*silence.cpp replaces mausc.c. New algorithm. Optionally uses Qt/Myth libs in order to send messages to mythplayer.
 
*silence.py replaces mausc-wrapper.py. I've updated the deprecated arg parsing, integrated Myth logging and added channel/prog preset handling. It can reside anywhere but I keep mine in /usr/local/bin. It expects the C++ executables to reside in /usr/local/bin/
 
*silence.py replaces mausc-wrapper.py. I've updated the deprecated arg parsing, integrated Myth logging and added channel/prog preset handling. It can reside anywhere but I keep mine in /usr/local/bin. It expects the C++ executables to reside in /usr/local/bin/
  
=== MythPlayer Interaction ===
+
=== Upgrading from previous versions ===
Commercial markups are stored in the Myth database. The script writes to the database (via the Python bindings) as commercials are detected. However MythPlayer only reads them from the database once, when playback starts. Thus it will not be aware of any commercials that have been detected after playback started. This situation occurs mainly when watching programmes whilst they are still recording.
+
This version communicates with MythPlayer via the Myth Python bindings. Previous versions communicated directly which (optionally) needed Myth & Qt header files to be installed. If you installed libmyth-dev & libqt4-dev just for this reason then they are no longer needed. However be wary of simply uninstalling them - that may break Myth as they also contain libraries. To remove them correctly you will probably have to reinstall Qt & Myth afterwards. It's safer to leave them installed.
 
 
MythPlayer is capable of being notified of newly flagged commercials via messages. However to do this, the script needs to be built with Myth & Qt header files, which are not usually installed on packaged Myth installations such as MythBuntu. It is possible to install the requisite headers on such systems. However I am not familiar enough with them to comment on the wisdom of doing so. I believe it is safe but be aware that the packages also contain libraries - updates could break Myth if they are not synchronised correctly.
 
 
 
If you do not install the headers and watch in-progress recordings (whilst they are being flagged), then periodically quitting playback and re-starting it will refresh the commercial markups.
 
 
 
As presented, the script does not need the headers and will build on packaged systems. If you have installed the headers then uncomment the first line of silence.cpp so that USE_HEADERS is defined. This will enable the messaging functionality.
 
 
 
Currently adverts are flagged <maxsep> after they finishes. An experimental feature enables flagging <mindetect> after an advert starts, which may be useful when watching close to real-time. However, it is currently ineffective because MythPlayer prohibits comm-skipping close to programme end.
 
 
 
Ideally MythPlayer notification should be done via Python - I'm investigating this.
 
  
 
=== Requirements ===
 
=== Requirements ===
 
*Compilation environment (gcc, make) - install package build-essential
 
*Compilation environment (gcc, make) - install package build-essential
 
*[http://www.mega-nerd.com/libsndfile/ libsndfile] for reading audio samples - install package libsndfile-dev
 
*[http://www.mega-nerd.com/libsndfile/ libsndfile] for reading audio samples - install package libsndfile-dev
*Optionally, Myth & Qt header files in /usr/include for communicating with MythPlayer. See [[#MythPlayer Interaction]]. These are not usually installed by packaged versions of Myth, ie. Mythbuntu, but can be found in packages libmyth-dev and libqt4-dev.
+
*Python 2.7 for the new argument parser
  
 
=== Building ===
 
=== Building ===
*Copy catagrower.cpp, silence.cpp, silence.py & Makefile to a new directory and cd there.
+
*Copy silence.cpp, silence.py & Makefile to a new directory and cd there.
*Modify silence.cpp if you have headers installed. See [[#MythPlayer Interaction]]
+
*Build the silence executable using "make"
*Build the silence & catagrower executables using "make"
 
 
*Install executables & Python script to /usr/local/bin/ using "sudo make install".  
 
*Install executables & Python script to /usr/local/bin/ using "sudo make install".  
*The Makefile works for me using gcc 4.6.3 (Ubuntu 12.04) & Myth 0.26. I'm no expert on C++ standards so earlier versions may need some tinkering.
+
*The Makefile works for me using gcc 4.6.3 (Ubuntu 12.04) & Myth 0.26. I'm no expert on C++ standards so earlier versions may need some tinkering.
*If using the headers, theoretically you should rebuild after every Myth update. However, the headers used rarely change so the script will probably continue to run.
 
  
 
=== Notes ===
 
=== Notes ===
 
*I only use Freeview SD, so I downmix my stereo reception to 1 channel to improve performance. Refer to Hippo's comments above regarding the number of channels and update silence.py (kUpmix_Channels) accordingly.
 
*I only use Freeview SD, so I downmix my stereo reception to 1 channel to improve performance. Refer to Hippo's comments above regarding the number of channels and update silence.py (kUpmix_Channels) accordingly.
*I also reduce the audio sample rate (silence.py line 177, "-ar 8000") to reduce the data throughput. Ultimately all channels/samples are reduced to a single audio power per frame and I haven't noticed any qualitative difference from this optimisation. However it could affect the mythffmpeg load; if loading/performance is important to you, you may wish to experiment with this.
+
*You can reduce the audio sample rate (add "-ar 8000" to the avconv command line) to reduce the data throughput. Ultimately all channels/samples are reduced to a single audio power per frame and I haven't noticed any qualitative difference from this optimisation. However it could affect the mythffmpeg load; if loading/performance is important to you, you may wish to experiment with this. I noticed that this doubles the CPU used by avconv without saving any measurable CPU in silence.cpp.
 
*silence.py uses mythffmpeg but, as Hippo states, you can simply replace with avconv/ffmpeg. I notice no difference.
 
*silence.py uses mythffmpeg but, as Hippo states, you can simply replace with avconv/ffmpeg. I notice no difference.
 
*<minbreak> and <mindetect> do not apply to pre-roll/post-roll (starting/ending) 'adverts'.
 
*<minbreak> and <mindetect> do not apply to pre-roll/post-roll (starting/ending) 'adverts'.
*All programmes will be processed. However, if it originates from a channel marked as comm-free, only pre-roll and post-roll breaks will be detected. This is useful for finding the start of BBC programmes.
 
 
*Mythplayer will not auto-skip pre-roll/post-roll breaks. When starting playback you need to manually comm-skip to the programme start.
 
*Mythplayer will not auto-skip pre-roll/post-roll breaks. When starting playback you need to manually comm-skip to the programme start.
 
*The log information can be initially confusing - bear in mind the algorithmic process when interpreting it. The interval of a silence always relates to the previous silence; the interval reported by a cluster always relates to the previous cluster. Silences report their audio power whereas clusters report the number of silences they contain.
 
*The log information can be initially confusing - bear in mind the algorithmic process when interpreting it. The interval of a silence always relates to the previous silence; the interval reported by a cluster always relates to the previous cluster. Silences report their audio power whereas clusters report the number of silences they contain.
Line 379: Line 72:
 
=== Running ===
 
=== Running ===
 
Assuming you use the same locations, your 'Advert-detection command' (mythtv-setup/General/Page 8) should be:
 
Assuming you use the same locations, your 'Advert-detection command' (mythtv-setup/General/Page 8) should be:
   /usr/local/bin/silence.py %JOBID% %VERBOSELEVEL% --loglevel debug
+
   /usr/local/bin/silence.py %JOBID% %VERBOSEMODE% --loglevel debug
 
You can also run it manually from the command line like this:
 
You can also run it manually from the command line like this:
 
   silence.py --chanid 1004 --starttime 20130117220000 --loglevel debug
 
   silence.py --chanid 1004 --starttime 20130117220000 --loglevel debug
Line 393: Line 86:
  
 
However it's also possible to specify parameters to use for specific channels or programmes. A preset file defines values that override the defaults according to programme title or channel callsign. Only one preset can apply - the first applicable - so care is needed when deciding the order. The title/callsigns are considered to be Python regular expressions so beware of the meta-characters. The 8th field is ignored and so can be used for comments/notes. Specify a preset file using the --presetfile option, like this:
 
However it's also possible to specify parameters to use for specific channels or programmes. A preset file defines values that override the defaults according to programme title or channel callsign. Only one preset can apply - the first applicable - so care is needed when deciding the order. The title/callsigns are considered to be Python regular expressions so beware of the meta-characters. The 8th field is ignored and so can be used for comments/notes. Specify a preset file using the --presetfile option, like this:
   /usr/local/bin/silence.py %JOBID% %VERBOSELEVEL% --loglevel debug --presetfile /home/eric/.mythtv/silence.preset
+
   /usr/local/bin/silence.py %JOBID% %VERBOSEMODE% --loglevel debug --presetfile /home/eric/.mythtv/silence.preset
  
 
Once you understand the logging information you can easily tune your own channels/programmes by experimenting with the --preset option directly from a command line until you get decent results. For example;
 
Once you understand the logging information you can easily tune your own channels/programmes by experimenting with the --preset option directly from a command line until you get decent results. For example;
   silence.py --chanid 1004 --starttime 20130117220000 --loglevel debug --preset "-80,,3,,180"
+
   silence.py --chanid=1004 --starttime=20130117220000 --loglevel=debug --preset="-80,,3,,180"
 +
 
 +
=== Timezone Issues ===
 +
As of v0.26 the Myth database uses UTC time. However the Python bindings (used by the script) use localtime by default. Therefore determining the proper starttime argument can be frustrating, as it depends on your timezone and DST. Using an ISO format starttime (YYYY-MM-DDThh:mm:ss+hh:mm) is useful here. For example, in a timezone of UTC+9 both of the following examples will find a recording that started at 9:58pm.
 +
 
 +
This will allow you to specify a UTC time (as derived from the Myth database 'recorded' table);
 +
  env TZ=UTC silence.py --chanid=1004 --starttime=2013-01-17T12:58:00
 +
 
 +
Or use local time and add a TZ qualifier;
 +
  silence.py --chanid=1004 --starttime=2013-01-17T21:58:00+09:00
  
 
This is my preset file which customises the processing of 4 regular programmes and 'tunes' some channels.
 
This is my preset file which customises the processing of 4 regular programmes and 'tunes' some channels.
Line 430: Line 132:
 
}}
 
}}
  
=== TroubleShooting ===
+
=== Australian Channel Presets ===
*If you get "Local access to recording not found" errors then ensure your Storage Group directories (mythtv-setup/Storage Groups) have backslashes on the end. See [[http://www.gossamer-threads.com/lists/mythtv/users/531768#531768]]
+
 
*If your comflagging jobs report 126/127 adverts found, this signifies an error when trying to run the job. Check the file permissions for the executables.
+
The following preset file is configured to suit Australian HD and SD Freeview channels. The defaults provided work well for many channels and shows, the exception being Nine's group of channels which require a different audio threshold. No effort has (yet) been made to tune for individual shows.
*If you're using headers and the script stops working after updating Myth, then ensure the headers are up-to-date and rebuild.
 
  
{{Code box|catagrower.cpp|
+
{{Code box|silence_au.preset|
 
<pre>  
 
<pre>  
/* Copyright 2012 Crackers Phipps. */
+
# presets for silence.py
/* Public domain. */
+
# use comma separated values: defaults are used for absent values
 +
# For titles/callsign the name is a python regular expressions, case is ignored.
 +
# Re Metachars are # . ^ $ * + ? { } [ ] \ | ( )
 +
# If a title contains one of these, then escape it (using \) or replace it with full stop
 +
# Names are matched to the START of a title/callsign so "e4" also matches "e4+1"
 +
# First name match is used so put specific presets (ie. programmes) before general ones (channels)
 +
#
 +
# title/callsign, threshold, minquiet, mindetect, minbreak, maxsep, padding
 +
# defaults          -75,      0.16,      6,      120,    120,    0.48,
 +
#
 +
# Defaults for Australian Freeview channels.
 +
NINE DIGITAL,      -73,      0.16,      6,      150,      60,    0.48,
 +
GEM,                -73,      0.16,      5,      120,      60,    0.48,
 +
GO!,                -73,      0.16,      5,      120,      60,    0.48,
 +
7 Digital,          -75,      0.16,      5,      150,      60,    0.48,
  
/* MythTV files are often large. */
+
#ABC1 - No ads, have not bothered attempting to configure for preroll or postroll.
#define _FILE_OFFSET_BITS 64
+
#ABC News 24 - No ads
 +
#ABC2 - ABC4 - No ads, have not bothered attempting to configure for preroll or postroll.
 +
#ABC3 - No ads, have not bothered attempting to configure for preroll or postroll.
 +
#7mate – defaults working okay with limited testing
 +
#7TWO – defaults working okay so far
 +
#TEN Digital – defaults working okay so far
 +
#ELEVEN – defaults working okay so far
 +
#ONE – defaults working okay so far
 +
#SBS ONE – defaults working okay with limited testing
 +
#SBS TWO – defaults working okay with limited testing
 +
#SBS HD – defaults working okay with limited testing
 +
</pre>
 +
}}
  
#include <stdio.h>
 
#include <stdlib.h>
 
#include <fcntl.h>
 
#include <unistd.h>
 
  
static void usage(const char *name) {
+
=== German Channel Presets ===
  fprintf(stderr, "Usage: %s <file> <timeout>\n", name);
 
  fprintf(stderr, "<file>  : file to be monitored.\n");
 
  fprintf(stderr, "<timeout>: secs to wait for input.\n");
 
  fprintf(stderr, "The contents of the file will be copied to stdout.\n");
 
  fprintf(stderr, "Copying will stop when the file has stopped growing.\n");
 
}
 
  
int main(int argc, char **argv) {
+
The following preset file is configured to suit German HD and SD Freeview channels. The defaults provided work well for many channels and shows, the exception being ProSieben which require a different audio threshold and minquiet. Not all channels was tested yet.
  
  /* Check usage. */
+
{{Code box|silence.preset|
  if (3 != argc) {
+
<pre>
    usage(argv[0]);
+
# presets for silence.py
    exit(1);
+
# use comma separated values: defaults are used for absent values
   }
+
#
 +
# For titles/callsign the name is a python regular expression, case is ignored.
 +
# Re Metachars are # . ^ $ * + ? { } [ ] \ | ( )
 +
# If a title contains one of these, then escape it (using \) or replace it with full stop
 +
# Names are matched to the START of a title/callsign so "e4" also matches "e4+1"
 +
# First name match is used so put specific presets (ie. programmes) before general ones (channels)
 +
#
 +
# threshold: (float)  silence threshold in dB.
 +
# minquiet : (float) minimum time for silence detection in seconds.
 +
# mindetect: (float) minimum number of silences to constitute an advert.
 +
# minlength: (float) minimum length of advert break in seconds.
 +
# maxsep   : (float)  maximum time between silences in an advert break in seconds.
 +
# padding  : (float)  padding for each cut point in seconds.
 +
#
 +
# title/callsign, threshold, minquiet, mindetect, minlength, maxsep, padding
 +
# defaults      ,      -75,    0.16,        6,      120,    120,    0.48
 +
#
  
  /* Load options. */
+
prosieben maxx,-105,,,,,1
  int fd;
+
prosieben,-90,0.12,,,,1
  if (-1 == (fd = open(argv[1], O_RDONLY))) {
 
    fprintf(stderr, "Could not open %s for reading.\n", argv[1]);
 
    usage(argv[0]);
 
    exit(2);
 
  }
 
  const int timeout = atoi(argv[2]);
 
  
#define BUFFSIZE 4096
+
# channels doing well with defaults
  int timer = timeout;
+
# kabel eins
  char buffer[BUFFSIZE];
+
# sat.1
  int bytes;
+
# rtl austria
  while (timer > 0) {
+
# rtl2
    while (0 != (bytes = read(fd, buffer, BUFFSIZE))) {
+
# sixx
      if (-1 == write (STDOUT_FILENO, buffer, bytes)){
+
# super rtl
          fprintf(stderr, "Write failed.\n");
+
# vox
          exit(3);
 
        }
 
      timer = timeout;
 
    }
 
    sleep(1);
 
    timer--;
 
  }
 
  return 0;
 
}
 
 
</pre>
 
</pre>
 
}}
 
}}
  
 +
 +
=== Trouble Shooting ===
 +
*If you get "Can't access file <filename> from <SG>" errors then ensure your Storage Group directories (mythtv-setup/Storage Groups) have backslashes on the end. See [[http://www.gossamer-threads.com/lists/mythtv/users/531768#531768]]
 +
*If your comflagging jobs report 126/127 adverts found, this signifies an error when trying to run the job. Check the file permissions for the executables.
 +
 +
=== Code ===
 +
==== silence.cpp ====
 
{{Code box|silence.cpp|
 
{{Code box|silence.cpp|
 
<pre>
 
<pre>
Line 498: Line 223:
 
// v1.0 Roger Siddons
 
// v1.0 Roger Siddons
 
// v2.0 Roger Siddons: Flag clusters asap, fix segfaults, optional headers
 
// v2.0 Roger Siddons: Flag clusters asap, fix segfaults, optional headers
// Public domain. Requires libsndfile, libQtCore, libmythbase-0.x, libmyth-0.x
+
// v3.0 Roger Siddons: Remove lib dependencies & commfree
 +
// v4.0 Kill process argv[1] when idle for 30 seconds.
 +
// v4.1 Fix averaging overflow
 +
// v4.2 Unblock the alarm signal so the job actually finishes.
 +
// Public domain. Requires libsndfile
 
// Detects commercial breaks using clusters of audio silences
 
// Detects commercial breaks using clusters of audio silences
 
// Uncomment this if you have header files for Myth & Qt installed in /usr/include
 
// It enables MythPlayer update messages (for watching a programme whilst it is commflagging)
 
//#define USE_HEADERS 1
 
 
// Experimental: Only effective with a modified MythPlayer that allows comm-skipping close to prog end.
 
// If true, MythPlayer is notified as soon as an advert starts, then again as the advert grows in size.
 
// If false, MythPlayer will only be notified once per advert, shortly after the advert has finished
 
const bool kUseAggressiveFlagging = false;
 
  
 
#include <cstdlib>
 
#include <cstdlib>
Line 517: Line 237:
 
#include <sndfile.h>
 
#include <sndfile.h>
 
#include <unistd.h>
 
#include <unistd.h>
 
+
#include <signal.h>
#ifdef USE_HEADERS
 
#include <mythcorecontext.h>
 
#include <mythcontext.h>
 
#include <mythversion.h>
 
#include <programtypes.h>
 
#include <QCoreApplication>
 
#endif
 
 
 
#define DELIMITER '@' // must correlate with python wrapper
 
  
 
typedef unsigned frameNumber_t;
 
typedef unsigned frameNumber_t;
 
typedef unsigned frameCount_t;
 
typedef unsigned frameCount_t;
  
void error(const char* mesg, bool die = true) {
+
// Output to python wrapper requires prefix to indicate level
     printf("err%c%s\n", DELIMITER, mesg);
+
#define DELIMITER "@" // must correlate with python wrapper
 +
char prefixdebug[7] = "debug" DELIMITER;
 +
char prefixinfo[6]  = "info" DELIMITER;
 +
char prefixerr[5]  = "err" DELIMITER;
 +
char prefixcut[5]  = "cut" DELIMITER;
 +
 
 +
void error(const char* mesg, bool die = true)
 +
{
 +
     printf("%s%s\n", prefixerr, mesg);
 
     if (die)
 
     if (die)
 
         exit(1);
 
         exit(1);
 +
}
 +
 +
pid_t tail_pid = 0;
 +
void watchdog(int sig)
 +
{
 +
    if (0 != tail_pid)
 +
        kill(tail_pid, SIGTERM);
 
}
 
}
  
Line 542: Line 268:
 
const float kvideoRate = 25.0;  // sample rate in fps (maps time to frame count)
 
const float kvideoRate = 25.0;  // sample rate in fps (maps time to frame count)
 
const frameCount_t krateInMins = kvideoRate * 60; // frames per min
 
const frameCount_t krateInMins = kvideoRate * 60; // frames per min
int useThreshold;               // Audio level of silence
+
unsigned useThreshold;         // Audio level of silence
 
frameCount_t useMinQuiet;      // Minimum length of a silence to register
 
frameCount_t useMinQuiet;      // Minimum length of a silence to register
 
unsigned useMinDetect;          // Minimum number of silences that constitute an advert
 
unsigned useMinDetect;          // Minimum number of silences that constitute an advert
Line 548: Line 274:
 
frameCount_t useMaxSep;        // silences must be closer than this to be in the same cluster
 
frameCount_t useMaxSep;        // silences must be closer than this to be in the same cluster
 
frameCount_t usePad;            // padding for each cut
 
frameCount_t usePad;            // padding for each cut
int commfree;                  // bool: true if prog originates from a comm-free channel
 
char progId[40];                // programme ident: should be no bigger than "cccc_yyyy-mm-ddThh:mm:ss+hh:mm"
 
  
 
void usage()
 
void usage()
 
{
 
{
     error("Usage: silence <threshold> <minquiet> <mindetect> <minlength> <maxsep> <pad> <commfree> <progid>", false);
+
     error("Usage: silence <tail_pid> <threshold> <minquiet> <mindetect> <minlength> <maxsep> <pad>", false);
 +
    error("<tail_pid> : (int)    Process ID to be killed after idle timeout.", false);
 
     error("<threshold>: (float)  silence threshold in dB.", false);
 
     error("<threshold>: (float)  silence threshold in dB.", false);
 
     error("<minquiet> : (float)  minimum time for silence detection in seconds.", false);
 
     error("<minquiet> : (float)  minimum time for silence detection in seconds.", false);
Line 560: Line 285:
 
     error("<maxsep>  : (float)  maximum time between silences in an advert break in seconds.", false);
 
     error("<maxsep>  : (float)  maximum time between silences in an advert break in seconds.", false);
 
     error("<pad>      : (float)  padding for each cut point in seconds.", false);
 
     error("<pad>      : (float)  padding for each cut point in seconds.", false);
     error("<commfree> : (int)    1 if prog is comm-free, 0 otherwise.", false);
+
     error("AU format audio is expected on stdin.", false);
    error("<progid>  : (string) chan_starttime of program (for player updates).", false);
+
     error("Example: silence 4567 -75 0.1 5 60 90 1 < audio.au");
    error("An AU format file should be fed into this program.", false);
 
     error("Example: silence -75 0.1 5 60 90 0 1004_20121003090000 < audio.au");
 
 
}
 
}
  
Line 569: Line 292:
 
// Parse args and convert to useable values (frames)
 
// Parse args and convert to useable values (frames)
 
{
 
{
     if (9 != argc)
+
     if (8 != argc)
 
         usage();
 
         usage();
  
Line 580: Line 303:
  
 
     /* Load options. */
 
     /* Load options. */
     if (1 != sscanf(argv[1], "%f", &argThreshold))
+
     if (1 != sscanf(argv[1], "%d", &tail_pid))
 +
        error("Could not parse tail_pid option into a number");
 +
    if (1 != sscanf(argv[2], "%f", &argThreshold))
 
         error("Could not parse threshold option into a number");
 
         error("Could not parse threshold option into a number");
     if (1 != sscanf(argv[2], "%f", &argMinQuiet))
+
     if (1 != sscanf(argv[3], "%f", &argMinQuiet))
 
         error("Could not parse minquiet option into a number");
 
         error("Could not parse minquiet option into a number");
     if (1 != sscanf(argv[3], "%f", &argMinDetect))
+
     if (1 != sscanf(argv[4], "%f", &argMinDetect))
 
         error("Could not parse mindetect option into a number");
 
         error("Could not parse mindetect option into a number");
     if (1 != sscanf(argv[4], "%f", &argMinLength))
+
     if (1 != sscanf(argv[5], "%f", &argMinLength))
 
         error("Could not parse minlength option into a number");
 
         error("Could not parse minlength option into a number");
     if (1 != sscanf(argv[5], "%f", &argMaxSep))
+
     if (1 != sscanf(argv[6], "%f", &argMaxSep))
 
         error("Could not parse maxsep option into a number");
 
         error("Could not parse maxsep option into a number");
     if (1 != sscanf(argv[6], "%f", &argPad))
+
     if (1 != sscanf(argv[7], "%f", &argPad))
 
         error("Could not parse pad option into a number");
 
         error("Could not parse pad option into a number");
    if (1 != sscanf(argv[7], "%d", &commfree))
 
        error("Could not parse commfree option into a number");
 
    if (1 != sscanf(argv[8], "%s", progId))
 
        error("Could not parse progid option into a string");
 
  
 
     /* Scale threshold to integer range that libsndfile will use. */
 
     /* Scale threshold to integer range that libsndfile will use. */
Line 607: Line 328:
 
     usePad      = rint(argPad * kvideoRate + 0.5);
 
     usePad      = rint(argPad * kvideoRate + 0.5);
  
     printf("debug%cThreshold=%.1f, MinQuiet=%.2f, MinDetect=%.1f, MinLength=%.1f, MaxSep=%.1f,"
+
     printf("%sThreshold=%.1f, MinQuiet=%.2f, MinDetect=%.1f, MinLength=%.1f, MaxSep=%.1f, Pad=%.2f\n",
          " Pad=%.2f\n", DELIMITER, argThreshold, argMinQuiet, argMinDetect,
+
          prefixdebug, argThreshold, argMinQuiet, argMinDetect, argMinLength, argMaxSep, argPad);
          argMinLength, argMaxSep, argPad);
+
     printf("%sFrame rate is %.2f, Detecting silences below %d that last for at least %d frames\n",
     printf("debug%cFrame rate is %.2f, Detecting silences below %d that last for at least %d frames\n",
+
           prefixdebug, kvideoRate, useThreshold, useMinQuiet);
           DELIMITER, kvideoRate, useThreshold, useMinQuiet);
+
     printf("%sClusters are composed of a minimum of %d silences closer than %d frames and must be\n",
     printf("debug%cClusters are composed of a minimum of %d silences closer than %d frames and must be\n",
+
           prefixdebug, useMinDetect, useMaxSep);
           DELIMITER, useMinDetect, useMaxSep);
+
     printf("%slonger than %d frames in total. Cuts will be padded by %d frames\n",
     printf("debug%clonger than %d frames in total. Cuts will be padded by %d frames\n",
+
           prefixdebug, useMinLength, usePad);
           DELIMITER, useMinLength, usePad);
+
     printf("%s< preroll, > postroll, - advert, ? too few silences, # too short, = comm flagged\n", prefixdebug);
     printf("debug%c< preroll, > postroll, - advert, ? too few silences, # too short, = comm flagged\n",
+
     printf("%s           Start - End    Start - End      Duration        Interval    Level/Count\n", prefixinfo);
          DELIMITER);
+
     printf("%s         frame - frame (mmm:ss-mmm:ss) frame (mm:ss.s)  frame (mmm:ss)\n", prefixinfo);
     printf("info%c           Start - End    Start - End      Duration        Interval    Level/Count\n", DELIMITER);
 
     printf("info%c         frame - frame (mmm:ss-mmm:ss) frame (mm:ss.s)  frame (mmm:ss)\n", DELIMITER);
 
}
 
}
 
 
 
namespace Comms
 
// Manages MythPlayer communications
 
{
 
#ifdef USE_HEADERS
 
QString updateMessage = "COMMFLAG_UPDATE "; // Player update message
 
#endif
 
 
 
void initialise(int argc, char **argv)
 
{
 
#ifdef USE_HEADERS
 
    // Require Myth context for sending player update messages
 
    QCoreApplication q(argc, argv);
 
    QCoreApplication::setApplicationName("silence");
 
 
 
    MythContext* gContext = new MythContext(MYTH_BINARY_VERSION);
 
 
 
    if (!gContext->Init( false, /*use gui*/
 
                        false, /*prompt for backend*/
 
                        false, /*bypass auto discovery*/
 
                        false)) /*ignoreDB*/
 
        error("Myth Context initialisation failed");
 
 
 
    gCoreContext->ConnectToMasterServer();
 
 
 
    // construct player update message
 
    updateMessage += QString(Arg::progId) + ' ';
 
#endif
 
}
 
 
 
void send(frameNumber_t start, frameNumber_t end, bool completed = false)
 
// Sends list of all commbreaks defined so far
 
{
 
#ifdef USE_HEADERS
 
    // requires comma seperator unless it's first advert
 
    QString sep = (updateMessage.endsWith(' ') ? "" : ",");
 
    // build new advert markups
 
    QString newBreak = QString("%1%2:%3,%4:%5").arg(sep).arg(start).arg(MARK_COMM_START)
 
            .arg(end).arg(MARK_COMM_END);
 
 
 
    if (!completed || (completed && !kUseAggressiveFlagging)) {
 
        // send completed breaks followed by the one being built
 
        gCoreContext->SendMessage(updateMessage + newBreak);
 
        printf("debug%c  Sent %s\n", DELIMITER, (updateMessage + newBreak).toAscii().constData());
 
    }
 
    if (completed)
 
        // append completed break to message
 
        updateMessage += newBreak;
 
#endif
 
 
}
 
}
 
}
 
}
Line 695: Line 363:
 
     {
 
     {
 
         end = frame;
 
         end = frame;
         length = frame -threshold)) {
+
         length = frame - start + 1;
    fprintf(stderr, "Could not parse threshold option into a number.\n");
 
    usage(argv[0]);
 
    exit(2);
 
  }
 
  if (1 != sscanf(argv[2], "%f",  start + 1;
 
 
         // maintain running average power: = (oldpower * (newlength - 1) + newpower)/ newlength
 
         // maintain running average power: = (oldpower * (newlength - 1) + newpower)/ newlength
 
         power += (_power - power)/length;
 
         power += (_power - power)/length;
Line 766: Line 429:
  
 
class ClusterList
 
class ClusterList
// Stores a list of detected silences and a list of assigned clusters
+
// Manages a list of detected silences and a list of assigned clusters
 
{
 
{
 
protected:
 
protected:
Line 805: Line 468:
 
     }
 
     }
 
};
 
};
 
ClusterList* clist; // List of completed silences & clusters
 
  
 
Silence* currentSilence; // the silence currently being detected/built
 
Silence* currentSilence; // the silence currently being detected/built
 
Cluster* currentCluster; // the cluster currently being built
 
Cluster* currentCluster; // the cluster currently being built
 +
ClusterList* clist;      // List of completed silences & clusters
  
 
void report(const char* err,
 
void report(const char* err,
Line 822: Line 484:
 
     frameCount_t duration = end - start + 1;
 
     frameCount_t duration = end - start + 1;
  
     printf("%s%c%c %7s %6d-%6d (%3d:%02ld-%3d:%02ld), %4d (%2d:%04.1f), %5d (%3d:%02ld), [%7d]\n",
+
     printf("%s%c %7s %6d-%6d (%3d:%02ld-%3d:%02ld), %4d (%2d:%04.1f), %5d (%3d:%02ld), [%7d]\n",
           err, DELIMITER, type, msg1, start, end,
+
           err, type, msg1, start, end,
 
           (start+13) / Arg::krateInMins, lrint(start / Arg::kvideoRate) % 60,
 
           (start+13) / Arg::krateInMins, lrint(start / Arg::kvideoRate) % 60,
 
           (end+13) / Arg::krateInMins, lrint(end / Arg::kvideoRate) % 60,
 
           (end+13) / Arg::krateInMins, lrint(end / Arg::kvideoRate) % 60,
Line 863: Line 525:
 
             currentCluster = new Cluster(currentSilence);
 
             currentCluster = new Cluster(currentSilence);
 
         }
 
         }
         report("debug", currentSilence->state_log[currentSilence->state], "Silence",
+
         report(prefixdebug, currentSilence->state_log[currentSilence->state], "Silence",
 
               currentSilence->start, currentSilence->end,
 
               currentSilence->start, currentSilence->end,
 
               currentSilence->interval, currentSilence->power);
 
               currentSilence->interval, currentSilence->power);
 
        // flag player asap for clusters at final state
 
        if (kUseAggressiveFlagging && currentCluster->state > Cluster::unset)
 
            Comms::send(currentCluster->padStart, currentCluster->padEnd);
 
  
 
         // silence is now owned by the list, start looking for next
 
         // silence is now owned by the list, start looking for next
Line 882: Line 540:
 
     clist->addCluster(currentCluster);
 
     clist->addCluster(currentCluster);
  
     report("info", currentCluster->state_log[currentCluster->state], "Cluster",
+
     report(prefixinfo, currentCluster->state_log[currentCluster->state], "Cluster",
 
           currentCluster->start->start, currentCluster->end->end,
 
           currentCluster->start->start, currentCluster->end->end,
 
           currentCluster->interval, currentCluster->silenceCount);
 
           currentCluster->interval, currentCluster->silenceCount);
  
     // only interested in clusters at final state
+
     // only flag clusters at final state
 
     if (currentCluster->state > Cluster::unset)
 
     if (currentCluster->state > Cluster::unset)
    {
+
         report(prefixcut, '=', "Cut", currentCluster->padStart, currentCluster->padEnd, 0, 0);
        // flag player with completed cluster
+
 
        Comms::send(currentCluster->padStart, currentCluster->padEnd, true);
 
        // log cut
 
         report("cut", '=', "Cut", currentCluster->padStart, currentCluster->padEnd, 0, 0);
 
    }
 
 
     // cluster is now owned by the list, start looking for next
 
     // cluster is now owned by the list, start looking for next
 
     currentCluster = NULL;
 
     currentCluster = NULL;
Line 901: Line 555:
 
// Detect silences and allocate to clusters
 
// Detect silences and allocate to clusters
 
{
 
{
 +
    // Remove logging prefixes if writing to terminal
 +
    if (isatty(1))
 +
        prefixcut[0] = prefixinfo[0] = prefixdebug[0] = prefixerr[0] = '\0';
 +
 +
    // flush output buffer after every line
 +
    setvbuf(stdout, NULL, _IOLBF, 0);
 +
 +
    Arg::parse(argc, argv);
 +
 
     /* Check the input is an audiofile. */
 
     /* Check the input is an audiofile. */
 
     SF_INFO metadata;
 
     SF_INFO metadata;
 
     SNDFILE* input = sf_open_fd(STDIN_FILENO, SFM_READ, &metadata, SF_FALSE);
 
     SNDFILE* input = sf_open_fd(STDIN_FILENO, SFM_READ, &metadata, SF_FALSE);
 
     if (NULL == input) {
 
     if (NULL == input) {
         sf_perror(NULL);
+
         error("libsndfile error:", false);
         return sf_error(NULL);
+
         error(sf_strerror(NULL));
 
     }
 
     }
 
    Arg::parse(argc, argv);
 
 
    Comms::initialise(argc, argv);
 
  
 
     /* Allocate data buffer to contain audio data from one video frame. */
 
     /* Allocate data buffer to contain audio data from one video frame. */
Line 917: Line 576:
  
 
     int* samples = (int*)malloc(frameSamples * sizeof(int));
 
     int* samples = (int*)malloc(frameSamples * sizeof(int));
     if (NULL == samples) {
+
     if (NULL == samples)
         perror(NULL);
+
         error("Couldn't allocate memory");
        return errno;
 
    }
 
  
 
     // create silence/cluster list
 
     // create silence/cluster list
 
     clist = new ClusterList();
 
     clist = new ClusterList();
  
     // flush output buffer after every line
+
     // Kill head of pipeline if timeout happens.
     setvbuf(stdout, NULL, _IOLBF, 0);
+
     signal(SIGALRM, watchdog);
 +
    sigset_t intmask;
 +
    sigemptyset(&intmask);
 +
    sigaddset(&intmask, SIGALRM);
 +
    sigprocmask(SIG_UNBLOCK, &intmask, NULL);
 +
    alarm(30);
  
 
     // Process the input one frame at a time and process cuts along the way.
 
     // Process the input one frame at a time and process cuts along the way.
Line 932: Line 594:
 
     while (frameSamples == static_cast<size_t>(sf_read_int(input, samples, frameSamples)))
 
     while (frameSamples == static_cast<size_t>(sf_read_int(input, samples, frameSamples)))
 
     {
 
     {
 +
        alarm(30);
 
         frames++;
 
         frames++;
  
 
         // determine average audio level in this frame
 
         // determine average audio level in this frame
         long avgabs = 0;
+
         unsigned long long avgabs = 0;
 
         for (unsigned i = 0; i < frameSamples; i++)
 
         for (unsigned i = 0; i < frameSamples; i++)
 
             avgabs += abs(samples[i]);
 
             avgabs += abs(samples[i]);
Line 964: Line 627:
 
         }
 
         }
 
     }
 
     }
     // Complete any current silence (if prog finished in silence)
+
     // Complete any current silence (prog may have finished in silence)
 
     if (currentSilence)
 
     if (currentSilence)
 
     {
 
     {
Line 985: Line 648:
 
}}
 
}}
  
 +
==== silence.py ====
 
{{Code box|silence.py|
 
{{Code box|silence.py|
 
<pre>
 
<pre>
 
#!/usr/bin/env python
 
#!/usr/bin/env python
 
# Build a skiplist from silence in the audio track.
 
# Build a skiplist from silence in the audio track.
# Roger Siddons v1.0
+
# v1.0 Roger Siddons
# Roger Siddons v2.0 Fix progid for job/player messages
+
# v2.0 Fix progid for job/player messages
 +
# v3.0 Send player messages via Python
 +
# v3.1 Fix commflag status, pad preset. Improve style & make Python 3 compatible
 +
# v4.0 silence.cpp will kill the head of the pipeline (tail) when recording finished
 +
# v4.1 Use unicode for foreign chars
 +
# v4.2 Prevent BE writeStringList errors
 +
# v5.0 Improve exception handling/logging. Fix player messages (0.26+ only)
 +
# v5.1 explicity set the bookmarkupdate value (for newer mysql)
  
 
import MythTV
 
import MythTV
Line 999: Line 670:
 
import re
 
import re
 
import sys
 
import sys
 +
import datetime
  
kExe_Catagrower = '/usr/local/bin/catagrower'
+
kExe_Silence = '/usr/local/bin/silence'
kExe_Silence   = '/usr/local/bin/silence'
+
kUpmix_Channels = '6' # Change this to 2 if you never have surround sound in your recordings.
kUpmix_Channels = '1'
 
kInput_Timeout  = '60'
 
  
class MYLOG( MythTV.MythLog ):
+
class MYLOG(MythTV.MythLog):
    "A specialised logger"
+
  "A specialised logger"
  
    def __init__(self, db):
+
  def __init__(self, db):
        "Initialise logging"
+
    "Initialise logging"
        MythTV.MythLog.__init__(self, 'm', db)
+
    MythTV.MythLog.__init__(self, '', db)
  
    def log(self, msg, level=MythTV.MythLog.INFO):
+
  def log(self, msg, level = MythTV.MythLog.INFO):
        "Log message"
+
    "Log message"
        # prepend string to msg so that rsyslog routes it to correct logfile
+
    # prepend string to msg so that rsyslog routes it to mythcommflag.log logfile
        MythTV.MythLog.log(self, MythTV.MythLog.COMMFLAG, level, 'mythcommflag: ' + msg.rstrip('\n'))
+
    MythTV.MythLog.log(self, MythTV.MythLog.COMMFLAG, level, 'mythcommflag: ' + msg.rstrip('\n'))
  
 
class PRESET:
 
class PRESET:
    "Manages the presets (parameters passed to the detection algorithm)"
+
  "Manages the presets (parameters passed to the detection algorithm)"
  
    # define arg ordering and default values
+
  # define arg ordering and default values
    argname = ['thresh', 'minquiet', 'mindetect', 'minbreak', 'maxsep', 'pad']
+
  argname = ['thresh', 'minquiet', 'mindetect', 'minbreak', 'maxsep', 'pad']
    argval  = [  -75,      0.16,        6,          120,      120,    0.48]
+
  argval  = [  -75,      0.16,        6,          120,      120,    0.48]
    # dictionary holds value for each arg
+
  # dictionary holds value for each arg
    argdict = collections.OrderedDict(zip(argname, argval))  
+
  argdict = collections.OrderedDict(list(zip(argname, argval)))
 +
 
 +
  def _validate(self, k, v):
 +
    "Converts arg input from string to float or None if invalid/not supplied"
 +
    if v is None or v == '':
 +
      return k, None
 +
    try:
 +
      return k, float(v)
 +
    except ValueError:
 +
      self.logger.log('Preset ' + k + ' (' + str(v) + ') is invalid - will use default',
 +
        MYLOG.ERR)
 +
      return k, None
  
    def _validate(self, k, v):
+
  def __init__(self, _logger):
        "Converts arg input from string to float or None if invalid/not supplied"
+
    "Initialise preset manager"
        if v is None or v == '':
+
    self.logger = _logger
            return k, None
 
        try:
 
            return k, float(v)
 
        except ValueError:
 
            self.logger.log('Preset ' + k + ' (' + str(v) + ') is invalid - will use default',
 
                MYLOG.ERR)
 
            return k, None
 
  
    def __init__(self, _logger):
+
  def getFromArg(self, line):
        "Initialise preset manager"
+
    "Parses preset values from command-line string"
        self.logger = _logger
+
    self.logger.log('Parsing presets from "' + line + '"', MYLOG.DEBUG)
 +
    if line:  # ignore empty string
 +
      vals = [i.strip() for i in line.split(',')]  # split individual params
 +
      # convert supplied values to float & match to appropriate arg name
 +
      validargs = list(map(self._validate, self.argname, vals[0:len(self.argname)]))
 +
      # remove missing/invalid values from list & replace default values with the rest
 +
      self.argdict.update(v for v in validargs if v[1] is not None)
  
    def getFromArg(self, line):
+
  def getFromFile(self, filename, title, callsign):
        "Parses preset values from command-line string"
+
    "Gets preset values from a file"
        self.logger.log('Parsing presets from "' + line + '"', MYLOG.DEBUG)
+
    self.logger.log('Using preset file "' + filename + '"', MYLOG.DEBUG)
         if line: # ignore empty string
+
    try:
             vals = [i.strip() for i in line.split(',')] # split individual params
+
      with open(filename) as presets:
             # convert supplied values to float & match to appropriate arg name
+
         for rawline in presets:
            validargs = map(self._validate, self.argname, vals)
+
          line = rawline.strip()
            # remove missing/invalid values from list & replace default values with the rest
+
          if line and (not line.startswith('#')): # ignore empty & comment lines
            self.argdict.update(dict(filter(lambda (k,v): False if v is None else (k,v), validargs)))
+
             vals = [i.strip() for i in line.split(',')] # split individual params
 +
             # match preset name to recording title or channel
 +
            pattern = re.compile(vals[0], re.IGNORECASE)
 +
            if pattern.match(title) or pattern.match(callsign):
 +
              self.logger.log('Using preset "' + line.strip() + '"')
 +
              # convert supplied values to float & match to appropriate arg name
 +
              validargs = list(map(self._validate, self.argname,
 +
                        vals[1:1 + len(self.argname)]))
 +
              # remove missing/invalid values from list &
 +
              # replace default values with the rest
 +
              self.argdict.update(v for v in validargs if v[1] is not None)
 +
              break
 +
        else:
 +
          self.logger.log('No preset found for "' + title.encode('utf-8') + '" or "' + callsign.encode('utf-8') + '"')
 +
    except IOError:
 +
      self.logger.log('Presets file "' + filename + '" not found', MYLOG.ERR)
 +
    return self.argdict
  
    def getFromFile(self, filename, title, callsign):
+
  def getValues(self):
        "Gets preset values from a file"
+
    "Returns params as a list of strings"
        self.logger.log('Using preset file "' + filename + '"', MYLOG.DEBUG)
+
    return [str(i) for i in list(self.argdict.values())]
        try:
 
            with open(filename) as presets:
 
                for rawline in presets:
 
                    line = rawline.strip()
 
                    if line and (not line.startswith('#')): # ignore empty & comment lines
 
                        vals = [i.strip() for i in line.split(',')] # split individual params
 
                        # match preset name to recording title or channel
 
                        pattern = re.compile(vals[0], re.IGNORECASE)
 
                        if pattern.match(title) or pattern.match(callsign):
 
                            self.logger.log('Using preset "' + line.strip() + '"')
 
                            # convert supplied values to float & match to appropriate arg name
 
                            validargs = map(self._validate, self.argname, vals[1:min(len(vals),len(self.argname))])
 
                            # remove missing/invalid values from list & replace default values with the rest
 
                            self.argdict.update(dict(filter(lambda (k,v): False if v is None else (k,v), validargs)))
 
                            break
 
                else:
 
                    self.logger.log('No preset found for "' + title + '" or "' + callsign + '"')
 
        except IOError:
 
            self.logger.log('Presets file "' + filename + '" not found', MYLOG.ERR)
 
        return self.argdict
 
  
    def getValues(self):
 
        "Returns params as a list of strings"
 
        return [str(i) for i in self.argdict.values()]
 
  
 
def main():
 
def main():
    "Commflag a recording"
+
  "Commflag a recording"
 
+
  try:
 
     # define options
 
     # define options
 
     parser = argparse.ArgumentParser(description='Commflagger')
 
     parser = argparse.ArgumentParser(description='Commflagger')
     parser.add_argument('--preset',  
+
     parser.add_argument('--preset', help='Specify values as "Threshold, MinQuiet, MinDetect, MinLength, MaxSep, Pad"')
        help='Specify values as "Threshold, MinQuiet, MinDetect, MinLength, MaxSep, Pad"')
 
 
     parser.add_argument('--presetfile', help='Specify file containing preset values')
 
     parser.add_argument('--presetfile', help='Specify file containing preset values')
     parser.add_argument('--chanid', help='Use chanid for manual operation')
+
     parser.add_argument('--chanid', type=int, help='Use chanid for manual operation')
 
     parser.add_argument('--starttime', help='Use starttime for manual operation')
 
     parser.add_argument('--starttime', help='Use starttime for manual operation')
 +
    parser.add_argument('--dump', action="store_true", help='Generate stack trace of exception')
 
     parser.add_argument('jobid', nargs='?', help='Myth job id')
 
     parser.add_argument('jobid', nargs='?', help='Myth job id')
  
Line 1,098: Line 771:
 
     args = parser.parse_args()
 
     args = parser.parse_args()
  
 +
    # connect to backend
 
     db = MythTV.MythDB()
 
     db = MythTV.MythDB()
 
     logger = MYLOG(db)
 
     logger = MYLOG(db)
 +
    be = MythTV.BECache(db=db)
  
 +
    logger.log('') # separate jobs in logfile
 
     if args.jobid:
 
     if args.jobid:
        job = MythTV.Job(args.jobid, db)
+
      logger.log('Starting job %s'%args.jobid, MYLOG.INFO)
        chanid = job.chanid
+
      job = MythTV.Job(args.jobid, db)
        starttime = job.starttime
+
      chanid = job.chanid
        timeout = kInput_Timeout
+
      starttime = job.starttime
 
     elif args.chanid and args.starttime:
 
     elif args.chanid and args.starttime:
        job = None
+
      job = None
        chanid = args.chanid
+
      chanid = args.chanid
         starttime = args.starttime
+
      try:
         timeout = '1'
+
        # only 0.26+
 +
         starttime = MythTV.datetime.duck(args.starttime)
 +
      except AttributeError:
 +
         starttime = args.starttimeaction="store_true"
 
     else:
 
     else:
        logger.log('Both chanid and starttime must be specified', MYLOG.ERR)
+
      logger.log('Both --chanid and -starttime must be specified', MYLOG.ERR)
        sys.exit(1)
+
      sys.exit(1)
  
     # get recording
+
     # mythplayer update message uses a 'chanid_utcTimeAsISODate' format to identify recording
 
     try:
 
     try:
        rec = MythTV.Recorded((chanid, starttime), db)
+
      # only 0.26+
     except:
+
      utc = starttime.asnaiveutc()
        if job:
+
     except AttributeError:
            job.update({'status':job.ERRORED, 'comment':'ERROR: Could not find recording.'})
+
      utc = starttime
        logger.log('Could not find recording', MYLOG.ERR)
 
        sys.exit(1)
 
  
 +
    progId = '%d_%s'%(chanid, str(utc).replace(' ', 'T'))
 +
 +
    # get recording
 +
    logger.log('Seeking chanid %s, starttime %s' %(chanid, starttime), MYLOG.INFO)
 +
    rec = MythTV.Recorded((chanid, starttime), db)
 
     channel = MythTV.Channel(chanid, db)
 
     channel = MythTV.Channel(chanid, db)
  
    logger.log('')
+
     logger.log('Processing: ' + channel.callsign.encode('utf-8') + ', ' + str(rec.starttime)
     logger.log('Processing: ' + str(channel.callsign) + ', ' + str(rec.starttime)
+
      + ', "' + rec.title.encode('utf-8') + ' - ' + rec.subtitle.encode('utf-8')+ '"')
        + ', "' + str(rec.title) + ' - ' + str(rec.subtitle) + '"')
 
 
 
    commfree = 1 if rec.commflagged == 3 else 0
 
    if commfree:
 
        logger.log('--- Comm-free programme - will detect pre-roll & post-roll adverts only')
 
  
 
     sg = MythTV.findfile(rec.basename, rec.storagegroup, db)
 
     sg = MythTV.findfile(rec.basename, rec.storagegroup, db)
 
     if sg is None:
 
     if sg is None:
        if job:
+
      logger.log("Can't access file %s from %s"%(rec.basename, rec.storagegroup), MYLOG.ERR)
            job.update({'status':job.ERRORED, 'comment':'ERROR: Local access to recording not found.'});
+
      try:
        logger.log('Local access to recording not found', MYLOG.ERR)
+
        job.update({'status': job.ERRORED, 'comment': "Couldn't access file"})
        sys.exit(1)
+
      except AttributeError : pass
 
+
      sys.exit(1)
    # player update message needs prog id (with time in Qt::ISODate format)
 
    progId = str(chanid) + '_' + str(starttime).replace(' ','T')
 
  
 
     # create params with default values
 
     # create params with default values
Line 1,148: Line 823:
 
     # read any supplied presets
 
     # read any supplied presets
 
     if args.preset:
 
     if args.preset:
        param.getFromArg(args.preset)
+
      param.getFromArg(args.preset)
     elif args.presetfile: # use preset file
+
     elif args.presetfile: # use preset file
        param.getFromFile(args.presetfile, rec.title, channel.callsign)
+
      param.getFromFile(args.presetfile, rec.title, channel.callsign)
  
 +
    # Pipe file through ffmpeg to extract uncompressed audio stream. Keep going till recording is finished.
 
     infile = os.path.join(sg.dirname, rec.basename)
 
     infile = os.path.join(sg.dirname, rec.basename)
 +
    p1 = subprocess.Popen(["tail", "--follow", "--bytes=+1", infile], stdout=subprocess.PIPE)
 +
    p2 = subprocess.Popen(["mythffmpeg", "-loglevel", "quiet", "-i", "pipe:0",
 +
                "-f", "au", "-ac", kUpmix_Channels, "-"],
 +
                stdin=p1.stdout, stdout=subprocess.PIPE)
 +
    # Pipe audio stream to C++ silence which will spit out formatted log lines
 +
    p3 = subprocess.Popen([kExe_Silence, "%d" % p1.pid] + param.getValues(), stdin=p2.stdout,
 +
                stdout=subprocess.PIPE)
  
 
     # Purge any existing skip list and flag as in-progress
 
     # Purge any existing skip list and flag as in-progress
 +
    rec.commflagged = 2
 
     rec.markup.clean()
 
     rec.markup.clean()
     rec.commflagged = 2
+
     rec.bookmarkupdate=datetime.datetime.now()
 
     rec.update()
 
     rec.update()
  
     # Write out the file contents and keep going till recording is finished.
+
     # Process log output from C++ silence
    p1 = subprocess.Popen([kExe_Catagrower, infile, timeout], stdout = subprocess.PIPE)
 
    # Pipe through ffmpeg to extract uncompressed audio stream.
 
    p2 = subprocess.Popen(["mythffmpeg", "-loglevel", "quiet", "-i", "pipe:0",
 
            "-ar", "8000", "-f", "au", "-ac", kUpmix_Channels, "-"],
 
            stdin = p1.stdout, stdout = subprocess.PIPE)
 
    # Pipe to silence which will spit out formatted log lines
 
    p3 = subprocess.Popen([kExe_Silence] + param.getValues() + [str(commfree)] + [progId],
 
    stdin = p2.stdout, stdout = subprocess.PIPE)
 
 
 
    # Process log output
 
 
     breaks = 0
 
     breaks = 0
     level = {'info':MYLOG.INFO, 'debug':MYLOG.DEBUG, 'err':MYLOG.ERR}
+
     level = {'info': MYLOG.INFO, 'debug': MYLOG.DEBUG, 'err': MYLOG.ERR}
 
     while True:
 
     while True:
        line = p3.stdout.readline()
+
      line = p3.stdout.readline()
        if line:
+
      if line:
            flag, info = line.split('@', 1)
+
        flag, info = line.split('@', 1)
            if flag == 'cut':
+
        if flag == 'cut':
                # extract numbers from log  
+
          # extract numbers from log line
                numbers = re.findall('\d+', info)
+
          numbers = re.findall('\d+', info)
                # mark advert in database
+
          logger.log(info)
                rec.markup.append(numbers[0], rec.markup.MARK_COMM_START, None)
+
          # mark advert in database
                rec.markup.append(numbers[1], rec.markup.MARK_COMM_END, None)
+
          rec.markup.append(int(numbers[0]), rec.markup.MARK_COMM_START, None)
                rec.update()
+
          rec.markup.append(int(numbers[1]), rec.markup.MARK_COMM_END, None)
                breaks += 1
+
          rec.update()
                logger.log(info)
+
          breaks += 1
            else:
+
          # send new advert skiplist to MythPlayers
                # use warning for unexpected log levels
+
          skiplist = ['%d:%d,%d:%d'%(x, rec.markup.MARK_COMM_START, y, rec.markup.MARK_COMM_END)
                logger.log(info, level.get(flag, MYLOG.WARNING))
+
                  for x, y in rec.markup.getskiplist()]
        else:
+
          mesg = 'COMMFLAG_UPDATE %s %s'%(progId, ','.join(skiplist))
          break
+
#         logger.log('  Sending %s'%mesg, MYLOG.DEBUG)
 
+
          result = be.backendCommand("MESSAGE[]:[]" + mesg)
    if job:
+
          if result != 'OK':
        job.update({'status':272,
+
            logger.log('Sending update message to backend failed, response = %s, message = %s'% (result, mesg), MYLOG.ERR)
                    'comment':'Audio commflag detected %s breaks.' % breaks});
+
        elif flag in level:
    logger.log('Audio commflag detected %s breaks.' % breaks)
+
          logger.log(info, level.get(flag))
 +
        else:  # unexpected prefix
 +
          # use warning for unexpected log levels
 +
          logger.log(flag, MYLOG.WARNING)
 +
      else:
 +
        break
  
 
     # Signal comflagging has finished
 
     # Signal comflagging has finished
     rec.commflagged = 3 if commfree else 1
+
     rec.commflagged = 1
 
     rec.update()
 
     rec.update()
 +
 +
    logger.log('Detected %s adverts.' % breaks)
 +
    try:
 +
      job.update({'status': 272, 'comment': 'Detected %s adverts.' % breaks})
 +
    except AttributeError : pass
 +
 +
    # Finishing too quickly can cause writeStringList/socket errors in the BE. (pre-0.28 only?)
 +
    # A short delay prevents this
 +
    import time
 +
    time.sleep(1)
 +
 +
  except Exception as e:
 +
    # get exception before we generate another
 +
    import traceback
 +
    exc_type, exc_value, frame = sys.exc_info()
 +
    # get stacktrace as a list
 +
    stack = traceback.format_exception(exc_type, exc_value, frame)
 +
 +
    # set status
 +
    status = 'Failed due to: "%s"'%e
 +
    try:
 +
      logger.log(status, MYLOG.ERR)
 +
    except : pass
 +
    try:
 +
      job.update({'status': job.ERRORED, 'comment': 'Failed.'})
 +
    except : pass
 +
 +
    # populate stack trace with vars
 +
    try:
 +
      if args.dump:
 +
        # insert the frame local vars after each frame trace
 +
        # i is the line index following the frame trace; 0 is the trace mesg, 1 is the first code line
 +
        i = 2
 +
        while frame is not None:
 +
          # format local vars
 +
          vars = []
 +
          for name, var in frame.tb_frame.f_locals.iteritems():
 +
            try:
 +
              text = '%s' % var
 +
              # truncate vars that are too long
 +
              if len(text) > 1000:
 +
                text = text[:1000] + '...'
 +
 +
            except Exception as e: # some var defs may be incomplete
 +
              text = '<Unknown due to: %s>'%e
 +
            vars.append('@ %s = %s'%(name, text))
 +
          # insert local stack contents after code trace line
 +
          stack.insert(i, '\n'.join(['@-------------'] + vars + ['@-------------\n']))
 +
          # advance over our insertion & the next code trace line
 +
          i += 2
 +
          frame = frame.tb_next
 +
        logger.log('\n'.join(stack), MYLOG.ERR)
 +
    except : pass
 +
    sys.exit(1)
  
 
if __name__ == '__main__':
 
if __name__ == '__main__':
    main()
+
  main()
 
</pre>
 
</pre>
 
}}
 
}}
  
 +
TimP in a mythtv-users [http://lists.mythtv.org/pipermail/mythtv-users/2020-April/403192.html post] has created a patch to update silence.py to work with Python3
 +
 +
==== patch.txt ====
 +
{{Code box|patch.txt|
 +
<pre>
 +
Index: /usr/local/bin/silence.py
 +
===================================================================
 +
--- /usr/local/bin/silence.py (revision 3088)
 +
+++ /usr/local/bin/silence.py (working copy)
 +
@@ -1,4 +1,4 @@
 +
-#!/usr/bin/env python
 +
+#!/usr/bin/env python3
 +
# Build a skiplist from silence in the audio track.
 +
# Roger Siddons v1.0
 +
# v2.0 Fix progid for job/player messages
 +
@@ -145,9 +145,8 @@
 +
 +
    channel = MythTV.Channel(chanid, db)
 +
 +
-    logger.log('')
 +
    logger.log('Processing: ' + str(channel.callsign) + ', ' + str(rec.starttime)
 +
-        + ', "' + rec.title.encode('utf-8') + ' - ' + rec.subtitle.encode('utf-8') + '"')
 +
+        + ', "' + rec.title + ' - ' + rec.subtitle + '"')
 +
 +
    sg = MythTV.findfile(rec.basename, rec.storagegroup, db)
 +
    if sg is None:
 +
@@ -183,7 +182,7 @@
 +
                          stdin=p1.stdout, stdout=subprocess.PIPE)
 +
    # Pipe to silence which will spit out formatted log lines
 +
    p3 = subprocess.Popen([kExe_Silence, "%d" % p1.pid] + param.getValues(), stdin=p2.stdout,
 +
-                          stdout=subprocess.PIPE)
 +
+                          stdout=subprocess.PIPE, text=True)
 +
 +
    # Process log output
 +
    breaks = 0
 +
</pre>
 +
}}
 +
 +
==== Makefile ====
 
{{Code box|Makefile|
 
{{Code box|Makefile|
 
<pre>
 
<pre>
CC= g++
+
CC       = g++
CFLAGS= -c -Wall -std=c++0x
+
CFLAGS   = -c -Wall -std=c++0x
INCPATH = -I/usr/include/qt4/QtCore -I/usr/include/qt4/QtNetwork -I/usr/include/qt4/QtSql -I/usr/include/qt4 -I/usr/include/mythtv
+
LIBPATH   = -L/usr/lib
LIBPATH = -L/usr/lib -L/usr/lib/i386-linux-gnu
+
TARGETDIR = /usr/local/bin
LIBS = -lsndfile -lQtCore -lmythbase-0.26 -lmyth-0.26
 
PREFIX = /usr/local/bin
 
  
 
.PHONY: clean install
 
.PHONY: clean install
  
all: silence catagrower
+
all: silence
 
catagrower: catagrower.o
 
$(CC) catagrower.o -o $@
 
 
 
 
silence: silence.o
 
silence: silence.o
$(CC) silence.o -o $@ $(LIBPATH) $(LIBS)
+
$(CC) silence.o -o $@ $(LIBPATH) -lsndfile
  
 
.cpp.o:
 
.cpp.o:
$(CC) $(CFLAGS) $(INCPATH) $< -o $@
+
$(CC) $(CFLAGS) $< -o $@
  
install: silence catagrower silence.py
+
install: silence silence.py
install -p -t $(PREFIX) $^
+
install -p -t $(TARGETDIR) $^
  
 
clean:  
 
clean:  
-rm -f silence catagrower *.o
+
-rm -f silence *.o
 
</pre>
 
</pre>
 
}}
 
}}

Latest revision as of 15:24, 28 April 2020


Author Hippo
Description A python/C++ program based on Mythcommflag-wrapper (thank you Cowbut) that works by detecting short silent periods around commercials.
Supports Version25.png  Version26.png Version27.png Version28.png 


Relevance

  • UK: Works well for Freeview/FreeSat SD/HD,
  • Australia: Works for Freeview SD/HD,
  • New Zealand: Works,
  • Germany: Works

Initial Version (by Hippo)

I tried out the scripts in Mythcommflag-wrapper and they worked well on the Freeview channels I receive but not on the FreeviewHD channels. The reason is that the audio on FreevieHD is an AAC stream and not an MP3 stream. Fixing that would require decoding from AAC and encoding back to MP3 before letting the script analyse the MP3 stream. So I wrote a little C program to analyze an uncompressed audio stream and a Python program to wrap it up and turn the output into a commercial skip list.

To use this

  • Compile the C program and put it somewhere the Python program can find it. (e.g. /usr/local/bin)
  • Copy the Python program to somehwere the backend can find it.
  • Follow the instructions on Mythcommflag-wrapper except the job setting should be 'silence.py %JOBID%'

The python program uses avconv to decode the program file to an AU stream. If you don't have avconv replace it with ffmpeg or mythffmpeg (avconv is the new name for ffmpeg). It upconverts the audio to 6 channels so that it works even when the audio switches around. If you know you only ever get stereo you can replace the 6 with 2 to save a bit of CPU power. It might have to go up in future. Up-converting is better because it's low power and always works whereas down-converting may fail depending on your version of avconv/ffmpeg.

This can do near-realtime commflagging by enabling the backend setting to start commflagging when the recording starts. (mythtv-setup/General/Page9-JobQueueGlobal). The programs mark entries in the cutlist <max-break-setting> after the start of a break is detected so this will be after the commercial break has ended. If you are displaying the programme and get too close to the end you will be in the commercials before they are flagged. C'est la vie.

It's low CPU in that it only decodes the audio stream and since it follows the end of the recording it shouldn't thrash the memory or disk. avconv takes about 2% to decode ITV1-HD on a 1.6GHz Atom Asus motherboard.

Cluster Detecting Version (by dizygotheca)

The basic silence detection algorithm is easily thrown by odd silences that occur within 6 mins of an advert and performs poorly on animations/kids programmes. I was keen to cut adverts out of my kids' shows so I developed an algorithm that detects clusters of silences: adverts are characterised by many silences close together whilst isolated silences within programmes are ignored.

Hippo has provided a good platform for a commflagging script. New features of this version are;

  • Determine ad breaks from clusters of silences. Solves those occasional glitches caused by silences within programmes and does a pretty good job on animations/kids programmes. Also allows the silence detection to be more sensitive (to pick up short and/or long silences) as rogue ones will be ignored.
  • Integrates the script with Myth logging. Works well with rsyslog (Mythbuntu). Should also work with file logging but I haven't tested it.
  • Allows parameters to be varied per-channel and per-programme. Useful for channels with 'noisier' ad breaks, ie. Dave, and regular programmes where the defaults don't suit you.
  • Sends ad breaks to mythplayer as they are found. If you start watching a prog before it has finished recording then the comm-skipping will still work (assuming you're not too close to real-time).

Algorithm

An advert is defined as a cluster of silences, at least <minbreak> long, that is composed of at least <mindetect> silences that occur within <maxsep> of each other.

In practice, silences are detected as a consecutive series of frames having an average audio power below <threshold> for at least <minquiet>. If the interval between a silence and the previous one is less than <maxsep> then they belong to the same cluster; otherwise they lie in different clusters. Clusters that are shorter than <minbreak> or composed of less than <mindetect> silences are ignored. Adverts are shortened by <padding> on both sides.

Although adverts are reported in real-time, all silences and clusters are stored - I originally envisaged using post-scan analysis to amend the detected adverts. However, so far, this hasn't proved necessary or viable.

Change Summary

  • silence.cpp replaces mausc.c. New algorithm. Optionally uses Qt/Myth libs in order to send messages to mythplayer.
  • silence.py replaces mausc-wrapper.py. I've updated the deprecated arg parsing, integrated Myth logging and added channel/prog preset handling. It can reside anywhere but I keep mine in /usr/local/bin. It expects the C++ executables to reside in /usr/local/bin/

Upgrading from previous versions

This version communicates with MythPlayer via the Myth Python bindings. Previous versions communicated directly which (optionally) needed Myth & Qt header files to be installed. If you installed libmyth-dev & libqt4-dev just for this reason then they are no longer needed. However be wary of simply uninstalling them - that may break Myth as they also contain libraries. To remove them correctly you will probably have to reinstall Qt & Myth afterwards. It's safer to leave them installed.

Requirements

  • Compilation environment (gcc, make) - install package build-essential
  • libsndfile for reading audio samples - install package libsndfile-dev
  • Python 2.7 for the new argument parser

Building

  • Copy silence.cpp, silence.py & Makefile to a new directory and cd there.
  • Build the silence executable using "make"
  • Install executables & Python script to /usr/local/bin/ using "sudo make install".
  • The Makefile works for me using gcc 4.6.3 (Ubuntu 12.04) & Myth 0.26. I'm no expert on C++ standards so earlier versions may need some tinkering.

Notes

  • I only use Freeview SD, so I downmix my stereo reception to 1 channel to improve performance. Refer to Hippo's comments above regarding the number of channels and update silence.py (kUpmix_Channels) accordingly.
  • You can reduce the audio sample rate (add "-ar 8000" to the avconv command line) to reduce the data throughput. Ultimately all channels/samples are reduced to a single audio power per frame and I haven't noticed any qualitative difference from this optimisation. However it could affect the mythffmpeg load; if loading/performance is important to you, you may wish to experiment with this. I noticed that this doubles the CPU used by avconv without saving any measurable CPU in silence.cpp.
  • silence.py uses mythffmpeg but, as Hippo states, you can simply replace with avconv/ffmpeg. I notice no difference.
  • <minbreak> and <mindetect> do not apply to pre-roll/post-roll (starting/ending) 'adverts'.
  • Mythplayer will not auto-skip pre-roll/post-roll breaks. When starting playback you need to manually comm-skip to the programme start.
  • The log information can be initially confusing - bear in mind the algorithmic process when interpreting it. The interval of a silence always relates to the previous silence; the interval reported by a cluster always relates to the previous cluster. Silences report their audio power whereas clusters report the number of silences they contain.
  • If processing manually, note that silence.py clears any existing comm-skip list on startup. Be aware that this also appears to erase the bookmark (and maybe other mark-ups).
  • UK commercials are usually 10-60 secs long. However I have seen occasional film trailers that are 2 min long (of constant noise). Thus <maxsep> defaults to 120. If you don't mind the odd trailer then reducing <maxsep> to 60 or 90 would probably reduce erroneous cuts.

Running

Assuming you use the same locations, your 'Advert-detection command' (mythtv-setup/General/Page 8) should be:

 /usr/local/bin/silence.py %JOBID% %VERBOSEMODE% --loglevel debug

You can also run it manually from the command line like this:

 silence.py --chanid 1004 --starttime 20130117220000 --loglevel debug

INFO logging shows details of the clusters/cuts, DEBUG logging also shows details of the detected silences.

My performance of SD content on an ageing ASUS M2NPV-VM/Athlon 3500+:

  • Flagging a completed recording on an idle system takes 2 min for a 1 hr recording
  • Flagging whilst recording uses about 2% of cpu

Channel Presets

When run on its own the Python program uses decent defaults that work pretty well.

However it's also possible to specify parameters to use for specific channels or programmes. A preset file defines values that override the defaults according to programme title or channel callsign. Only one preset can apply - the first applicable - so care is needed when deciding the order. The title/callsigns are considered to be Python regular expressions so beware of the meta-characters. The 8th field is ignored and so can be used for comments/notes. Specify a preset file using the --presetfile option, like this:

 /usr/local/bin/silence.py %JOBID% %VERBOSEMODE% --loglevel debug --presetfile /home/eric/.mythtv/silence.preset

Once you understand the logging information you can easily tune your own channels/programmes by experimenting with the --preset option directly from a command line until you get decent results. For example;

 silence.py --chanid=1004 --starttime=20130117220000 --loglevel=debug --preset="-80,,3,,180"

Timezone Issues

As of v0.26 the Myth database uses UTC time. However the Python bindings (used by the script) use localtime by default. Therefore determining the proper starttime argument can be frustrating, as it depends on your timezone and DST. Using an ISO format starttime (YYYY-MM-DDThh:mm:ss+hh:mm) is useful here. For example, in a timezone of UTC+9 both of the following examples will find a recording that started at 9:58pm.

This will allow you to specify a UTC time (as derived from the Myth database 'recorded' table);

 env TZ=UTC silence.py --chanid=1004 --starttime=2013-01-17T12:58:00

Or use local time and add a TZ qualifier;

 silence.py --chanid=1004 --starttime=2013-01-17T21:58:00+09:00

This is my preset file which customises the processing of 4 regular programmes and 'tunes' some channels.

Script.png silence.preset

 
# presets for silence.py
# use comma separated values: defaults are used for absent values
# For titles/callsign the name is a python regular expressions, case is ignored.
# Re Metachars are # . ^ $ * + ? { } [ ] \ | ( )
# If a title contains one of these, then escape it (using \) or replace it with full stop
# Names are matched to the START of a title/callsign so "e4" also matches "e4+1"
# First name match is used so put specific presets (ie. programmes) before general ones (channels)
#
# title/callsign, threshold, minquiet, mindetect, minbreak, maxsep, padding
# defaults          -75,       0.16,       6,       120,     120,    0.48,
#
frasier,               ,       0.28,        ,          ,      91,        , long pauses in prog
channel 4 news,        ,       1.00,       1,        55,        ,       0, short advert, many silences
milkshake,             ,       0.48,       8,        60,      61,        , ignore short silences in animation/links
rude tube,             ,       0.32,        ,       180,      61,        , ignore short silences in links

channel 4,             ,       0.24,
more 4,                ,       0.24,
dave,               -71,           ,        ,          ,        ,        , loud silences
quest,                 ,           ,        ,          ,      55,        , short silences, long breaks, short ads
channel 5,             ,       0.24,       2,          ,     300,        , cut news out of films 
itv,                   ,           ,        ,          ,        ,     1.0, long pad for films
film 4,                ,           ,        ,          ,        ,     1.0, long pad for films
bbc,                   ,       0.48,       1,        20,     360,       0, pre/post-roll  
cbeebies,              ,       0.48,       1,        20,     360,       0, pre/post-roll  
cbbc,                  ,       0.48,       1,        20,     360,       0, pre/post-roll  

Australian Channel Presets

The following preset file is configured to suit Australian HD and SD Freeview channels. The defaults provided work well for many channels and shows, the exception being Nine's group of channels which require a different audio threshold. No effort has (yet) been made to tune for individual shows.


Script.png silence_au.preset

 
# presets for silence.py
# use comma separated values: defaults are used for absent values
# For titles/callsign the name is a python regular expressions, case is ignored.
# Re Metachars are # . ^ $ * + ? { } [ ] \ | ( )
# If a title contains one of these, then escape it (using \) or replace it with full stop
# Names are matched to the START of a title/callsign so "e4" also matches "e4+1"
# First name match is used so put specific presets (ie. programmes) before general ones (channels)
#
# title/callsign, threshold, minquiet, mindetect, minbreak, maxsep, padding
# defaults          -75,       0.16,       6,       120,     120,    0.48,
#
# Defaults for Australian Freeview channels.
NINE DIGITAL,       -73,       0.16,       6,       150,      60,    0.48,
GEM,                -73,       0.16,       5,       120,      60,    0.48,
GO!,                -73,       0.16,       5,       120,      60,    0.48,
7 Digital,          -75,       0.16,       5,       150,      60,    0.48,

#ABC1 - No ads, have not bothered attempting to configure for preroll or postroll.
#ABC News 24 - No ads
#ABC2 - ABC4 - No ads, have not bothered attempting to configure for preroll or postroll.	
#ABC3 - No ads, have not bothered attempting to configure for preroll or postroll.
#7mate – defaults working okay with limited testing
#7TWO – defaults working okay so far
#TEN Digital – defaults working okay so far
#ELEVEN – defaults working okay so far
#ONE – defaults working okay so far
#SBS ONE – defaults working okay with limited testing
#SBS TWO – defaults working okay with limited testing
#SBS HD – defaults working okay with limited testing


German Channel Presets

The following preset file is configured to suit German HD and SD Freeview channels. The defaults provided work well for many channels and shows, the exception being ProSieben which require a different audio threshold and minquiet. Not all channels was tested yet.


Script.png silence.preset

 
# presets for silence.py
# use comma separated values: defaults are used for absent values
#
# For titles/callsign the name is a python regular expression, case is ignored.
# Re Metachars are # . ^ $ * + ? { } [ ] \ | ( )
# If a title contains one of these, then escape it (using \) or replace it with full stop
# Names are matched to the START of a title/callsign so "e4" also matches "e4+1"
# First name match is used so put specific presets (ie. programmes) before general ones (channels)
#
# threshold: (float)  silence threshold in dB.
# minquiet : (float)  minimum time for silence detection in seconds.
# mindetect: (float)  minimum number of silences to constitute an advert.
# minlength: (float)  minimum length of advert break in seconds.
# maxsep   : (float)  maximum time between silences in an advert break in seconds.
# padding  : (float)  padding for each cut point in seconds.
#
# title/callsign, threshold, minquiet, mindetect, minlength, maxsep, padding
# defaults      ,       -75,     0.16,         6,       120,    120,    0.48
#

prosieben maxx,-105,,,,,1
prosieben,-90,0.12,,,,1

# channels doing well with defaults
# kabel eins
# sat.1
# rtl austria
# rtl2
# sixx
# super rtl
# vox


Trouble Shooting

  • If you get "Can't access file <filename> from <SG>" errors then ensure your Storage Group directories (mythtv-setup/Storage Groups) have backslashes on the end. See [[1]]
  • If your comflagging jobs report 126/127 adverts found, this signifies an error when trying to run the job. Check the file permissions for the executables.

Code

silence.cpp

Script.png silence.cpp

// Based on mausc.c by Tinsel Phipps.
// v1.0 Roger Siddons
// v2.0 Roger Siddons: Flag clusters asap, fix segfaults, optional headers
// v3.0 Roger Siddons: Remove lib dependencies & commfree
// v4.0 Kill process argv[1] when idle for 30 seconds.
// v4.1 Fix averaging overflow
// v4.2 Unblock the alarm signal so the job actually finishes.
// Public domain. Requires libsndfile
// Detects commercial breaks using clusters of audio silences

#include <cstdlib>
#include <cmath>
#include <cerrno>
#include <climits>
#include <deque>
#include <sndfile.h>
#include <unistd.h>
#include <signal.h>

typedef unsigned frameNumber_t;
typedef unsigned frameCount_t;

// Output to python wrapper requires prefix to indicate level
#define DELIMITER "@" // must correlate with python wrapper
char prefixdebug[7] = "debug" DELIMITER;
char prefixinfo[6]  = "info" DELIMITER;
char prefixerr[5]   = "err" DELIMITER;
char prefixcut[5]   = "cut" DELIMITER;

void error(const char* mesg, bool die = true)
{
    printf("%s%s\n", prefixerr, mesg);
    if (die)
        exit(1);
}

pid_t tail_pid = 0;
void watchdog(int sig)
{
    if (0 != tail_pid)
        kill(tail_pid, SIGTERM);
}

namespace Arg
// Program argument management
{
const float kvideoRate = 25.0;  // sample rate in fps (maps time to frame count)
const frameCount_t krateInMins = kvideoRate * 60; // frames per min
unsigned useThreshold;          // Audio level of silence
frameCount_t useMinQuiet;       // Minimum length of a silence to register
unsigned useMinDetect;          // Minimum number of silences that constitute an advert
frameCount_t useMinLength;      // adverts must be at least this long
frameCount_t useMaxSep;         // silences must be closer than this to be in the same cluster
frameCount_t usePad;            // padding for each cut

void usage()
{
    error("Usage: silence <tail_pid> <threshold> <minquiet> <mindetect> <minlength> <maxsep> <pad>", false);
    error("<tail_pid> : (int)    Process ID to be killed after idle timeout.", false);
    error("<threshold>: (float)  silence threshold in dB.", false);
    error("<minquiet> : (float)  minimum time for silence detection in seconds.", false);
    error("<mindetect>: (float)  minimum number of silences to constitute an advert.", false);
    error("<minlength>: (float)  minimum length of advert break in seconds.", false);
    error("<maxsep>   : (float)  maximum time between silences in an advert break in seconds.", false);
    error("<pad>      : (float)  padding for each cut point in seconds.", false);
    error("AU format audio is expected on stdin.", false);
    error("Example: silence 4567 -75 0.1 5 60 90 1 < audio.au");
}

void parse(int argc, char **argv)
// Parse args and convert to useable values (frames)
{
    if (8 != argc)
        usage();

    float argThreshold; // db
    float argMinQuiet; // secs
    float argMinDetect;
    float argMinLength; // secs
    float argMaxSep; // secs
    float argPad; // secs

    /* Load options. */
    if (1 != sscanf(argv[1], "%d", &tail_pid))
        error("Could not parse tail_pid option into a number");
    if (1 != sscanf(argv[2], "%f", &argThreshold))
        error("Could not parse threshold option into a number");
    if (1 != sscanf(argv[3], "%f", &argMinQuiet))
        error("Could not parse minquiet option into a number");
    if (1 != sscanf(argv[4], "%f", &argMinDetect))
        error("Could not parse mindetect option into a number");
    if (1 != sscanf(argv[5], "%f", &argMinLength))
        error("Could not parse minlength option into a number");
    if (1 != sscanf(argv[6], "%f", &argMaxSep))
        error("Could not parse maxsep option into a number");
    if (1 != sscanf(argv[7], "%f", &argPad))
        error("Could not parse pad option into a number");

    /* Scale threshold to integer range that libsndfile will use. */
    useThreshold = rint(INT_MAX * pow(10, argThreshold / 20));

    /* Scale times to frames. */
    useMinQuiet  = ceil(argMinQuiet * kvideoRate);
    useMinDetect = (int)argMinDetect;
    useMinLength = ceil(argMinLength * kvideoRate);
    useMaxSep    = rint(argMaxSep * kvideoRate + 0.5);
    usePad       = rint(argPad * kvideoRate + 0.5);

    printf("%sThreshold=%.1f, MinQuiet=%.2f, MinDetect=%.1f, MinLength=%.1f, MaxSep=%.1f, Pad=%.2f\n",
           prefixdebug, argThreshold, argMinQuiet, argMinDetect, argMinLength, argMaxSep, argPad);
    printf("%sFrame rate is %.2f, Detecting silences below %d that last for at least %d frames\n",
           prefixdebug, kvideoRate, useThreshold, useMinQuiet);
    printf("%sClusters are composed of a minimum of %d silences closer than %d frames and must be\n",
           prefixdebug, useMinDetect, useMaxSep);
    printf("%slonger than %d frames in total. Cuts will be padded by %d frames\n",
           prefixdebug, useMinLength, usePad);
    printf("%s< preroll, > postroll, - advert, ? too few silences, # too short, = comm flagged\n", prefixdebug);
    printf("%s           Start - End    Start - End      Duration         Interval    Level/Count\n", prefixinfo);
    printf("%s          frame - frame (mmm:ss-mmm:ss) frame (mm:ss.s)  frame (mmm:ss)\n", prefixinfo);
}
}

class Silence
// Defines a silence
{
public:
    enum state_t {progStart, detection, progEnd};
    static const char state_log[3];

    const state_t state;       // type of silence
    const frameNumber_t start; // frame of start
    frameNumber_t end;         // frame of end
    frameCount_t length;       // number of frames
    frameCount_t interval;     // frames between end of last silence & start of this one
    double power;              // average power level

    Silence(frameNumber_t _start, double _power = 0, state_t _state = detection)
        : state(_state), start(_start), end(_start), length(1), interval(0), power(_power) {}

    void extend(frameNumber_t frame, double _power)
    // Define end of the silence
    {
        end = frame;
        length = frame - start + 1;
        // maintain running average power: = (oldpower * (newlength - 1) + newpower)/ newlength
        power += (_power - power)/length;
    }
};
// c++0x doesn't allow initialisation within class
const char Silence::state_log[3] = {'<', ' ', '>'};

class Cluster
// A cluster of silences
{
private:
    void setState()
    {
        if (this->start->start == 1)
            state = preroll;
        else if (this->end->state == Silence::progEnd)
            state = postroll;
        else if (length < Arg::useMinLength)
            state = tooshort;
        else if (silenceCount < Arg::useMinDetect)
            state = toofew;
        else
            state = advert;
    }

public:
    // tooshort..unset are transient states - they may be updated, preroll..postroll are final
    enum state_t {tooshort, toofew, unset, preroll, advert, postroll};
    static const char state_log[6];

    static frameNumber_t completesAt; // frame where the most recent cluster will complete

    state_t state;          // type of cluster
    const Silence* start;   // first silence
    Silence* end;           // last silence
    frameNumber_t padStart, padEnd; // padded cluster start/end frames
    unsigned silenceCount;  // number of silences
    frameCount_t length;    // number of frames
    frameCount_t interval;  // frames between end of last cluster and start of this one

    Cluster(Silence* s) : state(unset), start(s), end(s), silenceCount(1), length(s->length), interval(0)
    {
        completesAt = end->end + Arg::useMaxSep; // finish cluster <maxsep> beyond silence end
        setState();
        // pad everything except pre-rolls
        padStart = (state == preroll ? 1 : start->start + Arg::usePad);
    }

    void extend(Silence* _end)
    // Define end of a cluster
    {
        end = _end;
        silenceCount++;
        length = end->end - start->start + 1;
        completesAt = end->end + Arg::useMaxSep; // finish cluster <maxsep> beyond silence end
        setState();
        // pad everything except post-rolls
        padEnd = end->end - (state == postroll ? 0 : Arg::usePad);
    }
};
// c++0x doesn't allow initialisation within class
const char Cluster::state_log[6] = {'#', '?', '.', '<', '-', '>'};
frameNumber_t Cluster::completesAt = 0;

class ClusterList
// Manages a list of detected silences and a list of assigned clusters
{
protected:
    // list of detected silences
    std::deque<Silence*> silence;

    // list of deduced clusters of the silences
    std::deque<Cluster*> cluster;

public:
    Silence* insertStartSilence()
    // Inserts a fake silence at the front of the silence list
    {
        // create a single frame silence at frame 1 and insert it at front
        Silence* ref = new Silence(1, 0, Silence::progStart);
        silence.push_front(ref);
        return ref;
    }

    void addSilence(Silence* newSilence)
    // Adds a silence detection to the end of the silence list
    {
        // set interval between this & previous silence/prog start
        newSilence->interval = newSilence->start
                - (silence.empty() ? 1 : silence.back()->end - 1);
        // store silence
        silence.push_back(newSilence);
    }

    void addCluster(Cluster* newCluster)
    // Adds a cluster to end of the cluster list
    {
        // set interval between new cluster & previous one/prog start
        newCluster->interval = newCluster->start->start
                - (cluster.empty() ? 1 : cluster.back()->end->end - 1);
        // store cluster
        cluster.push_back(newCluster);
    }
};

Silence* currentSilence; // the silence currently being detected/built
Cluster* currentCluster; // the cluster currently being built
ClusterList* clist;      // List of completed silences & clusters

void report(const char* err,
            const char type,
            const char* msg1,
            const frameNumber_t start,
            const frameNumber_t end,
            const frameNumber_t interval,
            const int power)
// Logs silences/clusters/cuts in a standard format
{
    frameCount_t duration = end - start + 1;

    printf("%s%c %7s %6d-%6d (%3d:%02ld-%3d:%02ld), %4d (%2d:%04.1f), %5d (%3d:%02ld), [%7d]\n",
           err, type, msg1, start, end,
           (start+13) / Arg::krateInMins, lrint(start / Arg::kvideoRate) % 60,
           (end+13) / Arg::krateInMins, lrint(end / Arg::kvideoRate) % 60,
           duration, (duration+1) / Arg::krateInMins, fmod(duration / Arg::kvideoRate, 60),
           interval, (interval+13) / Arg::krateInMins, lrint(interval / Arg::kvideoRate) % 60, power);
}

void processSilence()
// Process a silence detection
{
    // ignore detections that are too short
    if (currentSilence->state == Silence::detection && currentSilence->length < Arg::useMinQuiet)
    {
        // throw it away
        delete currentSilence;
        currentSilence = NULL;
    }
    else
    {
        // record new silence
        clist->addSilence(currentSilence);

        // assign it to a cluster
        if (currentCluster)
        {
            // add to existing cluster
            currentCluster->extend(currentSilence);
        }
        else if (currentSilence->interval <= Arg::useMaxSep) // only possible for very first silence
        {
            // First silence is close to prog start so extend cluster to the start
            // by inserting a fake silence at prog start and starting the cluster there
            currentCluster = new Cluster(clist->insertStartSilence());
            currentCluster->extend(currentSilence);
        }
        else
        {
            // this silence is the start of a new cluster
            currentCluster = new Cluster(currentSilence);
        }
        report(prefixdebug, currentSilence->state_log[currentSilence->state], "Silence",
               currentSilence->start, currentSilence->end,
               currentSilence->interval, currentSilence->power);

        // silence is now owned by the list, start looking for next
        currentSilence = NULL;
    }
}

void processCluster()
// Process a completed cluster
{
    // record new cluster
    clist->addCluster(currentCluster);

    report(prefixinfo, currentCluster->state_log[currentCluster->state], "Cluster",
           currentCluster->start->start, currentCluster->end->end,
           currentCluster->interval, currentCluster->silenceCount);

    // only flag clusters at final state
    if (currentCluster->state > Cluster::unset)
        report(prefixcut, '=', "Cut", currentCluster->padStart, currentCluster->padEnd, 0, 0);

    // cluster is now owned by the list, start looking for next
    currentCluster = NULL;
}

int main(int argc, char **argv)
// Detect silences and allocate to clusters
{
    // Remove logging prefixes if writing to terminal
    if (isatty(1))
        prefixcut[0] = prefixinfo[0] = prefixdebug[0] = prefixerr[0] = '\0';

    // flush output buffer after every line
    setvbuf(stdout, NULL, _IOLBF, 0);

    Arg::parse(argc, argv);

    /* Check the input is an audiofile. */
    SF_INFO metadata;
    SNDFILE* input = sf_open_fd(STDIN_FILENO, SFM_READ, &metadata, SF_FALSE);
    if (NULL == input) {
        error("libsndfile error:", false);
        error(sf_strerror(NULL));
    }

    /* Allocate data buffer to contain audio data from one video frame. */
    const size_t frameSamples = metadata.channels * metadata.samplerate / Arg::kvideoRate;

    int* samples = (int*)malloc(frameSamples * sizeof(int));
    if (NULL == samples)
        error("Couldn't allocate memory");

    // create silence/cluster list
    clist = new ClusterList();

    // Kill head of pipeline if timeout happens.
    signal(SIGALRM, watchdog);
    sigset_t intmask;
    sigemptyset(&intmask);
    sigaddset(&intmask, SIGALRM);
    sigprocmask(SIG_UNBLOCK, &intmask, NULL);
    alarm(30);

    // Process the input one frame at a time and process cuts along the way.
    frameNumber_t frames = 0;
    while (frameSamples == static_cast<size_t>(sf_read_int(input, samples, frameSamples)))
    {
        alarm(30);
        frames++;

        // determine average audio level in this frame
        unsigned long long avgabs = 0;
        for (unsigned i = 0; i < frameSamples; i++)
            avgabs += abs(samples[i]);
        avgabs = avgabs / frameSamples;

        // check for a silence
        if (avgabs < Arg::useThreshold)
        {
            if (currentSilence)
            {
                // extend current silence
                currentSilence->extend(frames, avgabs);
            }
            else // transition to silence
            {
                // start a new silence
                currentSilence = new Silence(frames, avgabs);
            }
        }
        else if (currentSilence) // transition out of silence
        {
            processSilence();
        }
        // in noise: check for cluster completion
        else if (currentCluster && frames > currentCluster->completesAt)
        {
            processCluster();
        }
    }
    // Complete any current silence (prog may have finished in silence)
    if (currentSilence)
    {
        processSilence();
    }
    // extend any cluster close to prog end
    if (currentCluster && frames <= currentCluster->completesAt)
    {
        // generate a silence at prog end and extend cluster to it
        currentSilence = new Silence(frames, 0, Silence::progEnd);
        processSilence();
    }
    // Complete any final cluster
    if (currentCluster)
    {
        processCluster();
    }
}

silence.py

Script.png silence.py

#!/usr/bin/env python
# Build a skiplist from silence in the audio track.
# v1.0 Roger Siddons
# v2.0 Fix progid for job/player messages
# v3.0 Send player messages via Python
# v3.1 Fix commflag status, pad preset. Improve style & make Python 3 compatible
# v4.0 silence.cpp will kill the head of the pipeline (tail) when recording finished
# v4.1 Use unicode for foreign chars
# v4.2 Prevent BE writeStringList errors
# v5.0 Improve exception handling/logging. Fix player messages (0.26+ only)
# v5.1 explicity set the bookmarkupdate value (for newer mysql)

import MythTV
import os
import subprocess
import argparse
import collections
import re
import sys
import datetime

kExe_Silence = '/usr/local/bin/silence'
kUpmix_Channels = '6' # Change this to 2 if you never have surround sound in your recordings.

class MYLOG(MythTV.MythLog):
  "A specialised logger"

  def __init__(self, db):
    "Initialise logging"
    MythTV.MythLog.__init__(self, '', db)

  def log(self, msg, level = MythTV.MythLog.INFO):
    "Log message"
    # prepend string to msg so that rsyslog routes it to mythcommflag.log logfile
    MythTV.MythLog.log(self, MythTV.MythLog.COMMFLAG, level, 'mythcommflag: ' + msg.rstrip('\n'))

class PRESET:
  "Manages the presets (parameters passed to the detection algorithm)"

  # define arg ordering and default values
  argname = ['thresh', 'minquiet', 'mindetect', 'minbreak', 'maxsep', 'pad']
  argval  = [  -75,       0.16,        6,          120,       120,    0.48]
  # dictionary holds value for each arg
  argdict = collections.OrderedDict(list(zip(argname, argval)))

  def _validate(self, k, v):
    "Converts arg input from string to float or None if invalid/not supplied"
    if v is None or v == '':
      return k, None
    try:
      return k, float(v)
    except ValueError:
      self.logger.log('Preset ' + k + ' (' + str(v) + ') is invalid - will use default',
        MYLOG.ERR)
      return k, None

  def __init__(self, _logger):
    "Initialise preset manager"
    self.logger = _logger

  def getFromArg(self, line):
    "Parses preset values from command-line string"
    self.logger.log('Parsing presets from "' + line + '"', MYLOG.DEBUG)
    if line:  # ignore empty string
      vals = [i.strip() for i in line.split(',')]  # split individual params
      # convert supplied values to float & match to appropriate arg name
      validargs = list(map(self._validate, self.argname, vals[0:len(self.argname)]))
      # remove missing/invalid values from list & replace default values with the rest
      self.argdict.update(v for v in validargs if v[1] is not None)

  def getFromFile(self, filename, title, callsign):
    "Gets preset values from a file"
    self.logger.log('Using preset file "' + filename + '"', MYLOG.DEBUG)
    try:
      with open(filename) as presets:
        for rawline in presets:
          line = rawline.strip()
          if line and (not line.startswith('#')):  # ignore empty & comment lines
            vals = [i.strip() for i in line.split(',')]  # split individual params
            # match preset name to recording title or channel
            pattern = re.compile(vals[0], re.IGNORECASE)
            if pattern.match(title) or pattern.match(callsign):
              self.logger.log('Using preset "' + line.strip() + '"')
              # convert supplied values to float & match to appropriate arg name
              validargs = list(map(self._validate, self.argname,
                         vals[1:1 + len(self.argname)]))
              # remove missing/invalid values from list &
              # replace default values with the rest
              self.argdict.update(v for v in validargs if v[1] is not None)
              break
        else:
          self.logger.log('No preset found for "' + title.encode('utf-8') + '" or "' + callsign.encode('utf-8') + '"')
    except IOError:
      self.logger.log('Presets file "' + filename + '" not found', MYLOG.ERR)
    return self.argdict

  def getValues(self):
    "Returns params as a list of strings"
    return [str(i) for i in list(self.argdict.values())]


def main():
  "Commflag a recording"
  try:
    # define options
    parser = argparse.ArgumentParser(description='Commflagger')
    parser.add_argument('--preset', help='Specify values as "Threshold, MinQuiet, MinDetect, MinLength, MaxSep, Pad"')
    parser.add_argument('--presetfile', help='Specify file containing preset values')
    parser.add_argument('--chanid', type=int, help='Use chanid for manual operation')
    parser.add_argument('--starttime', help='Use starttime for manual operation')
    parser.add_argument('--dump', action="store_true", help='Generate stack trace of exception')
    parser.add_argument('jobid', nargs='?', help='Myth job id')

    # must set up log attributes before Db locks them
    MYLOG.loadArgParse(parser)
    MYLOG._setmask(MYLOG.COMMFLAG)

    # parse options
    args = parser.parse_args()

    # connect to backend
    db = MythTV.MythDB()
    logger = MYLOG(db)
    be = MythTV.BECache(db=db)

    logger.log('')	# separate jobs in logfile
    if args.jobid:
      logger.log('Starting job %s'%args.jobid, MYLOG.INFO)
      job = MythTV.Job(args.jobid, db)
      chanid = job.chanid
      starttime = job.starttime
    elif args.chanid and args.starttime:
      job = None
      chanid = args.chanid
      try:
        # only 0.26+
        starttime = MythTV.datetime.duck(args.starttime)
      except AttributeError:
        starttime = args.starttimeaction="store_true"
    else:
      logger.log('Both --chanid and -starttime must be specified', MYLOG.ERR)
      sys.exit(1)

    # mythplayer update message uses a 'chanid_utcTimeAsISODate' format to identify recording
    try:
      # only 0.26+
      utc = starttime.asnaiveutc()
    except AttributeError:
      utc = starttime

    progId = '%d_%s'%(chanid, str(utc).replace(' ', 'T'))

    # get recording
    logger.log('Seeking chanid %s, starttime %s' %(chanid, starttime), MYLOG.INFO)
    rec = MythTV.Recorded((chanid, starttime), db)
    channel = MythTV.Channel(chanid, db)

    logger.log('Processing: ' + channel.callsign.encode('utf-8') + ', ' + str(rec.starttime)
      + ', "' + rec.title.encode('utf-8') + ' - ' + rec.subtitle.encode('utf-8')+ '"')

    sg = MythTV.findfile(rec.basename, rec.storagegroup, db)
    if sg is None:
      logger.log("Can't access file %s from %s"%(rec.basename, rec.storagegroup), MYLOG.ERR)
      try:
        job.update({'status': job.ERRORED, 'comment': "Couldn't access file"})
      except AttributeError : pass
      sys.exit(1)

    # create params with default values
    param = PRESET(logger)
    # read any supplied presets
    if args.preset:
      param.getFromArg(args.preset)
    elif args.presetfile:  # use preset file
      param.getFromFile(args.presetfile, rec.title, channel.callsign)

    # Pipe file through ffmpeg to extract uncompressed audio stream. Keep going till recording is finished.
    infile = os.path.join(sg.dirname, rec.basename)
    p1 = subprocess.Popen(["tail", "--follow", "--bytes=+1", infile], stdout=subprocess.PIPE)
    p2 = subprocess.Popen(["mythffmpeg", "-loglevel", "quiet", "-i", "pipe:0",
                "-f", "au", "-ac", kUpmix_Channels, "-"],
                stdin=p1.stdout, stdout=subprocess.PIPE)
    # Pipe audio stream to C++ silence which will spit out formatted log lines
    p3 = subprocess.Popen([kExe_Silence, "%d" % p1.pid] + param.getValues(), stdin=p2.stdout,
                stdout=subprocess.PIPE)

    # Purge any existing skip list and flag as in-progress
    rec.commflagged = 2
    rec.markup.clean()
    rec.bookmarkupdate=datetime.datetime.now()
    rec.update()

    # Process log output from C++ silence
    breaks = 0
    level = {'info': MYLOG.INFO, 'debug': MYLOG.DEBUG, 'err': MYLOG.ERR}
    while True:
      line = p3.stdout.readline()
      if line:
        flag, info = line.split('@', 1)
        if flag == 'cut':
          # extract numbers from log line
          numbers = re.findall('\d+', info)
          logger.log(info)
          # mark advert in database
          rec.markup.append(int(numbers[0]), rec.markup.MARK_COMM_START, None)
          rec.markup.append(int(numbers[1]), rec.markup.MARK_COMM_END, None)
          rec.update()
          breaks += 1
          # send new advert skiplist to MythPlayers
          skiplist = ['%d:%d,%d:%d'%(x, rec.markup.MARK_COMM_START, y, rec.markup.MARK_COMM_END)
                   for x, y in rec.markup.getskiplist()]
          mesg = 'COMMFLAG_UPDATE %s %s'%(progId, ','.join(skiplist))
#         logger.log('  Sending %s'%mesg,  MYLOG.DEBUG)
          result = be.backendCommand("MESSAGE[]:[]" + mesg)
          if result != 'OK':
            logger.log('Sending update message to backend failed, response = %s, message = %s'% (result, mesg), MYLOG.ERR)
        elif flag in level:
          logger.log(info, level.get(flag))
        else:  # unexpected prefix
          # use warning for unexpected log levels
          logger.log(flag, MYLOG.WARNING)
      else:
        break

    # Signal comflagging has finished
    rec.commflagged = 1
    rec.update()

    logger.log('Detected %s adverts.' % breaks)
    try:
      job.update({'status': 272, 'comment': 'Detected %s adverts.' % breaks})
    except AttributeError : pass

    # Finishing too quickly can cause writeStringList/socket errors in the BE. (pre-0.28 only?)
    # A short delay prevents this
    import time
    time.sleep(1)

  except Exception as e:
    # get exception before we generate another
    import traceback
    exc_type, exc_value, frame = sys.exc_info()
    # get stacktrace as a list
    stack = traceback.format_exception(exc_type, exc_value, frame)

    # set status
    status = 'Failed due to: "%s"'%e
    try:
      logger.log(status, MYLOG.ERR)
    except : pass
    try:
      job.update({'status': job.ERRORED, 'comment': 'Failed.'})
    except : pass

    # populate stack trace with vars
    try:
      if args.dump:
        # insert the frame local vars after each frame trace
        # i is the line index following the frame trace; 0 is the trace mesg, 1 is the first code line
        i = 2
        while frame is not None:
          # format local vars
          vars = []
          for name, var in frame.tb_frame.f_locals.iteritems():
            try:
              text = '%s' % var
              # truncate vars that are too long
              if len(text) > 1000:
                text = text[:1000] + '...'

            except Exception as e: # some var defs may be incomplete
              text = '<Unknown due to: %s>'%e
            vars.append('@ %s = %s'%(name, text))
          # insert local stack contents after code trace line
          stack.insert(i, '\n'.join(['@-------------'] + vars + ['@-------------\n']))
          # advance over our insertion & the next code trace line
          i += 2
          frame = frame.tb_next
        logger.log('\n'.join(stack), MYLOG.ERR)
    except : pass
    sys.exit(1)

if __name__ == '__main__':
  main()

TimP in a mythtv-users post has created a patch to update silence.py to work with Python3

patch.txt

Script.png patch.txt

Index: /usr/local/bin/silence.py
===================================================================
--- /usr/local/bin/silence.py	(revision 3088)
+++ /usr/local/bin/silence.py	(working copy)
@@ -1,4 +1,4 @@
-#!/usr/bin/env python
+#!/usr/bin/env python3
 # Build a skiplist from silence in the audio track.
 # Roger Siddons v1.0
 # v2.0 Fix progid for job/player messages
@@ -145,9 +145,8 @@
 
     channel = MythTV.Channel(chanid, db)
 
-    logger.log('')
     logger.log('Processing: ' + str(channel.callsign) + ', ' + str(rec.starttime)
-        + ', "' + rec.title.encode('utf-8') + ' - ' + rec.subtitle.encode('utf-8') + '"')
+        + ', "' + rec.title + ' - ' + rec.subtitle + '"')
 
     sg = MythTV.findfile(rec.basename, rec.storagegroup, db)
     if sg is None:
@@ -183,7 +182,7 @@
                           stdin=p1.stdout, stdout=subprocess.PIPE)
     # Pipe to silence which will spit out formatted log lines
     p3 = subprocess.Popen([kExe_Silence, "%d" % p1.pid] + param.getValues(), stdin=p2.stdout,
-                          stdout=subprocess.PIPE)
+                          stdout=subprocess.PIPE, text=True)
 
     # Process log output
     breaks = 0

Makefile

Script.png Makefile

CC        = g++
CFLAGS    = -c -Wall -std=c++0x
LIBPATH   = -L/usr/lib
TARGETDIR = /usr/local/bin

.PHONY: clean install

all: silence
	
silence: silence.o
	$(CC) silence.o -o $@ $(LIBPATH) -lsndfile

.cpp.o:
	$(CC) $(CFLAGS) $< -o $@

install: silence silence.py
	install -p -t $(TARGETDIR) $^

clean: 
	-rm -f silence *.o