Difference between revisions of "Commercial detection with silences"

From MythTV Official Wiki
Jump to: navigation, search
(Add error reporting when run as job. Use bindings to handle `recordedmarkup` modification. Have logger apply additional arguments to OptionParser to allow MythTV's verbosity arguments to be used.)
Line 122: Line 122:
 
# Build a skiplist from silence in the audio track.
 
# Build a skiplist from silence in the audio track.
 
# Based on http://www.mythtv.org/wiki/Transcode_wrapper_stub
 
# Based on http://www.mythtv.org/wiki/Transcode_wrapper_stub
from MythTV import MythDB, Job, Recorded, findfile
+
from MythTV import MythDB, Job, Recorded, findfile, MythLog
 
from os import path
 
from os import path
 
from subprocess import Popen, PIPE
 
from subprocess import Popen, PIPE
 
from optparse import OptionParser
 
from optparse import OptionParser
 
def addskip(cr, rec, chanid, starttime, startframe, endframe):
 
    cr.execute("""INSERT INTO recordedmarkup (chanid, starttime, type, mark)
 
                  VALUES (%s, '%s', %s, %s);"""
 
              % (chanid, starttime, rec.markup.MARK_COMM_START, startframe))
 
    cr.execute("""INSERT INTO recordedmarkup (chanid, starttime, type, mark)
 
                  VALUES (%s, '%s', %s, %s);"""
 
              % (chanid, starttime, rec.markup.MARK_COMM_END, endframe))
 
    rec.commflagged = 1
 
  
 
def runjob(jobid=None, chanid=None, starttime=None):
 
def runjob(jobid=None, chanid=None, starttime=None):
Line 144: Line 135:
  
 
     db = MythDB()
 
     db = MythDB()
    cursor = db.cursor()
 
 
     if jobid:
 
     if jobid:
 
         job = Job(jobid, db=db)
 
         job = Job(jobid, db=db)
Line 153: Line 143:
 
         rec = Recorded((chanid, starttime), db=db)
 
         rec = Recorded((chanid, starttime), db=db)
 
     except:
 
     except:
         print 'Could not find recording.'
+
         if jobid:
 +
            job.update({'status':job.ERRORED,
 +
                        'comment':'ERROR: Could not find recording.'})
 +
        else:
 +
            print 'Could not find recording.'
 
         exit(1)
 
         exit(1)
  
Line 162: Line 156:
 
     sg = findfile(rec.basename, rec.storagegroup, db=db)
 
     sg = findfile(rec.basename, rec.storagegroup, db=db)
 
     if sg is None:
 
     if sg is None:
         print 'Local access to recording not found.'
+
         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)
 
         exit(1)
  
Line 168: Line 166:
  
 
     # Purge any existing skip list.
 
     # Purge any existing skip list.
     cursor.execute("""DELETE FROM recordedmarkup WHERE
+
     rec.markup.clean()
                      chanid = %s AND starttime = '%s' AND type = %s;"""
+
                  % (chanid, starttime, rec.markup.MARK_COMM_START))
+
    cursor.execute("""DELETE FROM recordedmarkup WHERE
+
                      chanid = %s AND starttime = '%s' AND type = %s;"""
+
                  % (chanid, starttime, rec.markup.MARK_COMM_END))
+
 
     rec.commflagged = 0
 
     rec.commflagged = 0
  
Line 198: Line 191:
 
         if int(end) - breakstart > maxbreak:
 
         if int(end) - breakstart > maxbreak:
 
             if 1 != breakend:
 
             if 1 != breakend:
                 addskip(cursor, rec, chanid, starttime, breakstart, breakend)
+
                 rec.markup.append(breakstart, rec.markup.MARK_COMM_START, None)
 +
                rec.markup.append(breakend, rec.markup.MARK_COMM_END, None)
 
                 breaks = breaks + 1
 
                 breaks = breaks + 1
 
             breakstart = int(start)
 
             breakstart = int(start)
Line 204: Line 198:
  
 
     if 1 != breakstart: # Add the last break if not flushed.
 
     if 1 != breakstart: # Add the last break if not flushed.
         addskip(cursor, rec, chanid, starttime, breakstart, breakend)
+
         rec.markup.append(breakstart, rec.markup.MARK_COMM_START, None)
 +
        rec.markup.append(breakend, rec.markup.MARK_COMM_END, None)
 
         breaks = breaks + 1
 
         breaks = breaks + 1
  
 
     # Commit to database.
 
     # Commit to database.
    cursor.close()
 
 
     rec.update()
 
     rec.update()
  
Line 223: Line 217:
 
     parser.add_option('--starttime', action='store', type='string',
 
     parser.add_option('--starttime', action='store', type='string',
 
                       dest='stime', help='Use starttime for manual operation')
 
                       dest='stime', help='Use starttime for manual operation')
 +
    MythLog.loadOptParse(parser)
 
     opts, args = parser.parse_args()
 
     opts, args = parser.parse_args()
  
Line 238: Line 233:
 
</pre>
 
</pre>
 
}}
 
}}
 +
 +
[[Category:Python_Scripts]]
 +
[[Category:User_Job_Scripts]]

Revision as of 16:43, 5 July 2012

Author Hippo
Description A python program based on Mythcommflag-wrapper (thank you Cowbut) that can be used on UK FreeviewHD channels and probably others.
Supports Version25.png  


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.
  • Copy the Python program to somehwere the backend can find it.
  • Follow the instructions on Mythcommflag-wrapper.

There is some SQL in here that should be done with the MythTV Python bindings but I couldn't figure out how to do it. I didn't really want to call up mythutil just to edit the skiplist.

The wrapper script uses avconv to decode the program file to an AU stream. If you don't have avconv replace it with ffmpeg (avconv is the new name for ffmpeg).

This does not do realtime commflagging but it might be able to by making the C program pace the input stream (it knows the data rate) The Python program would have to be changed to not buffer the output of the C program as well.

Script.png mausc.c

/* Copyright 2012 Tinsel Phipps. */
/* Public domain. Links with libsndfile which is GPL. */
/* Compile with
   gcc -std=c99 -Wall -Wextra -Werror -O mausc.c -o mausc -lsndfile -lm
*/
#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> <rate>\n", name);
  fprintf(stderr, "<threshold>: silence threshold in dB.\n");
  fprintf(stderr, "<min>: minimum time for silence detection 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, 25 < audio.au\n", name);
}

int main(int argc, char **argv) {

  /* Check usage. */
  if (4 != argc) {
    usage(argv[0]);
    exit(1);
  }

  /* Load options. */
  float threshold, min, 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", &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 min time to frames. */
  min = min * 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;
  }

  /* Indices of frame numbers of quiet periods. */
  int start = 0;
  int end = 0;
  /* Process the file one frame at a time and print out cuts along the way. */
  int frames = 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];
    }
    if (maxabs < threshold) {
      end = frames;
    } else {
      if (end - start > min) {
        printf("%d %d\n", start, end);
      }
      start = frames;
    }
  }
  return sf_close(input);
}


Script.png mausc-wrapper.py

#!/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)
    threshold = -70 # Silence threshold in dB.
    minsilence = 0.15 # Minimum time for silence detection in seconds.
    maxbreak = 400 # Maximum length of adverts breaks.
    framerate = 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

    # Extract uncompressed audio stream from recording.
    p1 = Popen(["avconv", "-v", "fatal", "-i", infile, "-f", "au", "-"],
               stdout = PIPE)
    # Pipe to mausc which will spit out a list of silent intervals in frames.
    p2 = Popen(["mausc", str(threshold), str(minsilence), str(framerate)],
               stdin = p1.stdout, stdout = PIPE)
    output, error = p2.communicate()

    # Convert maxbreak from seconds to frames.
    maxbreak = maxbreak * framerate

    # Coalesce short silences into larger breaks to skip.
    breaks = 0
    breakstart = 1
    breakend = 1
    for line in output.splitlines():
        start, end = line.split()
        # Uncomment the next two lines to not cut before the first break.
        #if 1 == breakstart:
        #    breakstart = int(start)
        if int(end) - breakstart > maxbreak:
            if 1 != breakend:
                rec.markup.append(breakstart, rec.markup.MARK_COMM_START, None)
                rec.markup.append(breakend, rec.markup.MARK_COMM_END, None)
                breaks = breaks + 1
            breakstart = int(start)
        breakend = end

    if 1 != breakstart: # Add the last break if not flushed.
        rec.markup.append(breakstart, rec.markup.MARK_COMM_START, None)
        rec.markup.append(breakend, rec.markup.MARK_COMM_END, None)
        breaks = breaks + 1

    # Commit to database.
    rec.update()

    if jobid:
        job.update({'status':272,
                    'comment':'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()