Skip to content

Instantly share code, notes, and snippets.

@jglim

jglim/Program.cs Secret

Last active January 4, 2024 20:31
Show Gist options
  • Star 12 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
  • Save jglim/0d8f8008b6d11258e56344020cf21364 to your computer and use it in GitHub Desktop.
Save jglim/0d8f8008b6d11258e56344020cf21364 to your computer and use it in GitHub Desktop.
ChapterID
using SoundFingerprinting;
using SoundFingerprinting.Audio;
using SoundFingerprinting.Builder;
using SoundFingerprinting.Configuration;
using SoundFingerprinting.Data;
using SoundFingerprinting.InMemory;
using SoundFingerprinting.Query;
using System;
using System.Threading.Tasks;
using System.Diagnostics;
using System.IO;
using System.Collections.Generic;
using System.Text;
namespace ChapterID
{
class Program
{
// This whole project works because of https://github.com/AddictedCS/soundfingerprinting (add this nuget dependency first!)
// VLC tip: seek chapters using shift+P (previous) , shift+N (next)
// ffmpeg: https://ffmpeg.org/download.html
public const string FfmpegPath = @"C:\ffmpeg_path\ffmpeg\bin\ffmpeg.exe";
// MKV tooling : https://www.fosshub.com/MKVToolNix.html
public const string MkvToolPath = @"C:\mkvtoolnix_path\mkvtoolnix\mkvmerge.exe";
// Folder containing input AVI files to be "chapterized"
public const string VideoFolder = @"C:\input_files\Charmed\";
// Intro audio track to listen out for
public const string IntroAudioTrack = "intro.wav";
public const string TimestampCsvName = @"timestamps.csv";
public const string CsvCellDelimiter = ";";
public const string CsvRowDelimiter = "\n";
// The input filename must contain this string to be processed (for filtering by episodes).
// Change this string to the file extension to disable filtering
public const string FilenameFilter = "S01E";
// How long is the intro, so that we can skip past this amount of time in seconds
public const int IntroductionPeriodToSkipSeconds = 52;
public static Dictionary<string, double> FingerprintedIntroTimestamps = new Dictionary<string, double>();
private static readonly IModelService modelService = new InMemoryModelService(); // store fingerprints in RAM
private static readonly IAudioService audioService = new SoundFingerprintingAudioService(); // default audio library
enum RunMode
{
FingerprintIntroTimestamps,
AssembleMkvWithChapters,
RunAll
}
static async Task Main(string[] args)
{
Console.WriteLine("Starting ChapterID");
// The different stages are segmented for easier development, since it is undesirable to repeat earlier stages (e.g. fingerprinting) if a later stage (mkv generation) fails
RunMode runMode = RunMode.AssembleMkvWithChapters;
if (runMode == RunMode.FingerprintIntroTimestamps)
{
// Run fingerprinting and generate csv ONLY
await RunFingerprintForAllVideos();
}
else if (runMode == RunMode.AssembleMkvWithChapters)
{
// Assemble mkv files from csv snapshot ONLY
AddMkvChapters();
}
else if (runMode == RunMode.RunAll)
{
// Fingerprint AND build mkv files
await RunFingerprintForAllVideos();
AddMkvChapters();
}
else
{
Console.WriteLine("No action specified");
}
Console.WriteLine("Done");
Console.ReadLine();
}
// Loads a previously stored set of timestamps from csv
public static void LoadTimestampRecords()
{
string[] csv = File.ReadAllText(TimestampCsvName).Trim().Split(CsvRowDelimiter);
FingerprintedIntroTimestamps.Clear();
foreach (string row in csv)
{
string[] cells = row.Split(CsvCellDelimiter);
FingerprintedIntroTimestamps.Add(cells[0], double.Parse(cells[1]));
}
}
// Loads file:timestamp records from csv, then creates "chapterized" mkvs
public static void AddMkvChapters()
{
LoadTimestampRecords();
foreach (KeyValuePair<string, double> row in FingerprintedIntroTimestamps)
{
Console.WriteLine($"{row.Value} @ {row.Key}");
// Convert the mkv, create the chapter data, then splice them together
string mkvPath = RunFfmpegConvertMkv(VideoFolder + row.Key);
string chapterPath = CreateChapterFile(row.Value);
string newMkvPath = RunMkvSpliceChapter(mkvPath, chapterPath);
// Cleanup: delete temporary mkv and chapter data
File.Delete(chapterPath);
File.Delete(mkvPath);
Console.WriteLine($"Fixed {newMkvPath}");
}
}
// Generates a chapter file containing 3 segments describing the intro's period, to be added into a mkv file
public static string CreateChapterFile(double startTimestamp)
{
string chapterFilePath = $"{GetStartupPath()}{Path.DirectorySeparatorChar}{Guid.NewGuid()}.txt";
TimeSpan startTs = TimeSpan.FromSeconds(startTimestamp);
TimeSpan endTs = TimeSpan.FromSeconds(startTimestamp + IntroductionPeriodToSkipSeconds);
string chapterNewline = "\n";
string chapterTemplate =
$"CHAPTER01=00:00:00.000{chapterNewline}CHAPTER01NAME=Opening{chapterNewline}" +
$"CHAPTER02=00:{startTs.Minutes:D2}:{startTs.Seconds:D2}.000{chapterNewline}CHAPTER02NAME=Introduction{chapterNewline}" +
$"CHAPTER03=00:{endTs.Minutes:D2}:{endTs.Seconds:D2}.300{chapterNewline}CHAPTER03NAME=Content";
File.WriteAllText(chapterFilePath, chapterTemplate);
return chapterFilePath;
}
// Given a directory of avi files, fingerprint for the intro track and store its results in FingerprintedIntroTimestamps
public static async Task RunFingerprintForAllVideos()
{
Console.WriteLine("Loaded intro chunk");
await StoreForLaterRetrieval(IntroAudioTrack);
// string targetFile = Directory.GetFiles(VideoFolder)[0]; // single-shot for debug
foreach (string targetFile in Directory.GetFiles(VideoFolder))
{
// constrain by format AND episode (since the signature might change)
if (Path.GetExtension(targetFile).ToLower() == ".avi" && targetFile.Contains(FilenameFilter))
{
await RunFingerprint(targetFile);
}
else
{
Console.WriteLine($"Skipping invalid file : {Path.GetFileName(targetFile)}");
}
}
SaveFingerprintResults();
}
// Writes results from FingerprintedIntroTimestamps into a csv file, and also prints FingerprintedIntroTimestamps contents
public static void SaveFingerprintResults()
{
StringBuilder csv = new StringBuilder();
Console.WriteLine("=================================\nResults\n=================================\n");
foreach (KeyValuePair<string, double> row in FingerprintedIntroTimestamps)
{
double roundedSecond = Math.Round(row.Value, 0);
Console.WriteLine($"{roundedSecond} @ {row.Key}");
csv.Append($"{row.Key}{CsvCellDelimiter}{roundedSecond}{CsvRowDelimiter}");
}
File.WriteAllText(TimestampCsvName, csv.ToString());
}
// Given an AVI file, find the timestamp of the intro, and store the results in FingerprintedIntroTimestamps
public static async Task RunFingerprint(string targetFile)
{
// extract the audio from the avi file
string extractedAudioTrack = RunFfmpegExtractWav(targetFile);
// fingerprint for intro track, results stored in FingerprintedIntroTimestamps
await RunFingerprintAsync(extractedAudioTrack, targetFile);
// clean up the temporary wave file
File.Delete(extractedAudioTrack);
}
// Gets the folder where the binary is executed in
public static string GetStartupPath()
{
return Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location) + Path.DirectorySeparatorChar;
}
// Extracts a .wav file from an AVI container
public static string RunFfmpegExtractWav(string aviPath)
{
string temporaryId = Guid.NewGuid().ToString();
string wavPath = $"{GetStartupPath()}{Path.DirectorySeparatorChar}{temporaryId}.wav";
// this specific command extracts ONLY the first 15 minutes of wave audio from the input AVI file.
string ffmpegArgs = $"-i \"{aviPath}\" -ss 00:00:00 -t 00:15:00.0 -vn -acodec pcm_s16le -ar 44100 -ac 1 \"{wavPath}\"";
Console.WriteLine($"Running ffmpeg\r\n input {aviPath}\r\n output at {wavPath}");
// Run ffmpeg, and wait for it to complete
Process.Start(new ProcessStartInfo(FfmpegPath, ffmpegArgs)).WaitForExit();
Console.WriteLine($"Wave extraction complete");
return wavPath;
}
// Converts the video *container* from AVI to MKV, as AVI does not seem to readily accept chapter data
public static string RunFfmpegConvertMkv(string aviPath)
{
// this ffmpeg command does not re-encode the video, preventing quality degradation
string mkvPath = $"{GetStartupPath()}{Path.DirectorySeparatorChar}{Path.GetFileNameWithoutExtension(aviPath)}.mkv";
string ffmpegArgs = $"-fflags +genpts -i \"{aviPath}\" -c:v copy -c:a copy \"{mkvPath}\"";
// Run ffmpeg, and wait for it to complete
Process.Start(new ProcessStartInfo(FfmpegPath, ffmpegArgs)).WaitForExit();
return mkvPath;
}
// Runs mkvmerge to splice a mkv file with a chapter file, and output a new mkv file
public static string RunMkvSpliceChapter(string mkvPath, string chapterPath)
{
string outputMkvPath = $"{GetStartupPath()}{Path.DirectorySeparatorChar}{Path.GetFileNameWithoutExtension(mkvPath)}_processed.mkv";
string mkvArgs = $" --chapters \"{chapterPath}\" -o \"{outputMkvPath}\" \"{mkvPath}\"";
// Run mkvmerge, and wait for it to complete
Process.Start(new ProcessStartInfo(MkvToolPath, mkvArgs)).WaitForExit();
return outputMkvPath;
}
// Checks a audio track for the presence and location of an intro track, then add it to the global results dictionary
// Modified from the SoundFingerprinting example
public static async Task RunFingerprintAsync(string pathToAudioFile, string originalAviPath)
{
QueryResult queryResult = await QueryCommandBuilder.Instance
.BuildQueryCommand()
.From(pathToAudioFile)
.UsingServices(modelService, audioService)
.Query();
string identifier = Path.GetFileName(originalAviPath);
if (queryResult.BestMatch is null)
{
Console.WriteLine("No matches");
FingerprintedIntroTimestamps.Add(identifier, -1);
}
else
{
double matchedTime = queryResult.BestMatch.QueryMatchStartsAt;
double matchedConfidence = queryResult.BestMatch.Confidence;
if (matchedConfidence < 0.3)
{
Console.WriteLine("Rejected low-confidence result");
FingerprintedIntroTimestamps.Add(identifier, -1);
}
else
{
Console.WriteLine($"Fingerprint query: confidence {matchedConfidence}, at {matchedTime}");
FingerprintedIntroTimestamps.Add(identifier, matchedTime);
}
}
}
// Adds a audio track to be recognized, in this case, the video's introduction track
// Mostly copied from the SoundFingerprinting example
public static async Task StoreForLaterRetrieval(string pathToAudioFile)
{
// Anything here is fine since we only want to know *when* the track is playing
var track = new TrackInfo("INTRO_ID", "INTRO_TITLE", "INTRO_ARTIST");
// Create fingerprint from the wav file
var hashedFingerprints = await FingerprintCommandBuilder.Instance
.BuildFingerprintCommand()
.From(pathToAudioFile)
.UsingServices(audioService)
.Hash();
// Store hashes in the database for later retrieval
modelService.Insert(track, hashedFingerprints);
}
}
}
@jeppevinkel
Copy link

Hello, isn't it supposed to be RunMode.RunAll on line 74 😄

@jglim
Copy link
Author

jglim commented Oct 28, 2021

Good catch — I made a mistake there. Thanks for pointing it out!

@jeppevinkel
Copy link

jeppevinkel commented Oct 28, 2021

I made some modifications with your code as a base to create a version that doesn't require manually supplying an intro. While I was at it I also added so it will also set chapters for end credits if they are present. It's not 100% perfect, but it works most of the time from my test. The code is here in case it's a topic you are still interested in 😊

@jglim
Copy link
Author

jglim commented Oct 29, 2021

Nice, without having to isolate the intro, your implementation is much easier to get started with. Thanks for sharing :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment