-
-
Save jglim/0d8f8008b6d11258e56344020cf21364 to your computer and use it in GitHub Desktop.
ChapterID
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
} | |
} | |
} |
Good catch — I made a mistake there. Thanks for pointing it out!
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 😊
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
Hello, isn't it supposed to be
RunMode.RunAll
on line 74 😄