Generate Structured Meeting Notes from Audio Recordings
Every organization records meetings, but few have the resources to turn hours of audio into structured, searchable notes. LM-Kit.NET lets you chain speech-to-text directly into LLM-powered summarization and formatting, converting raw meeting audio into professional notes with agendas, discussion summaries, decisions, and action items. The entire pipeline runs locally, ensuring that confidential board discussions, strategy sessions, and HR conversations never leave your infrastructure. This tutorial builds an automated meeting notes generator that produces structured Markdown output from audio files.
Why Automated Meeting Notes Matter
Two enterprise problems that automated meeting note generation solves:
- Executive and board meetings. C-suite meetings and board sessions produce critical decisions that must be documented for governance, compliance, and institutional memory. Manual note-taking is incomplete (the note-taker misses content while writing) and slow (professional minutes take hours to draft). An automated pipeline captures everything and produces structured minutes within seconds of the meeting ending.
- Distributed engineering teams. Global teams across time zones rely on recorded standups, design reviews, and retrospectives. Team members who could not attend need concise, structured notes rather than hour-long recordings. Automated notes with section headers let engineers jump directly to the decisions and action items relevant to their work.
Prerequisites
| Requirement | Minimum |
|---|---|
| .NET SDK | 8.0+ |
| VRAM | ~4.5 GB (Whisper model + chat model) |
| Disk | ~4 GB free for model downloads |
| Audio file | A .wav file (16-bit PCM, any sample rate) |
Step 1: Create the Project
dotnet new console -n MeetingNotesGenerator
cd MeetingNotesGenerator
dotnet add package LM-Kit.NET
Step 2: Understand the Pipeline
Meeting recording (.wav)
│
▼
┌──────────────────┐
│ SpeechToText │ Whisper model
│ (transcribe) │ Full transcript with timestamps
└────────┬─────────┘
│
▼
┌──────────────────┐
│ Summarizer │ Generate title + executive summary
│ (summarize) │
└────────┬─────────┘
│
▼
┌─────────────────────────────┐
│ SingleTurnConversation │ Structure as meeting notes
│ (format as meeting notes) │ with headers and sections
└────────┬────────────────────┘
│
▼
Structured meeting notes (Markdown)
├── Meeting title
├── Executive summary
├── Discussion topics
├── Decisions made
└── Action items
Step 3: Transcribe and Generate Meeting Notes
using System.Text;
using LMKit.Media.Audio;
using LMKit.Model;
using LMKit.Speech;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
LMKit.Licensing.LicenseManager.SetLicenseKey("");
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;
// ──────────────────────────────────────
// 1. Load Whisper model
// ──────────────────────────────────────
Console.WriteLine("Loading Whisper model...");
using LM whisperModel = LM.LoadFromModelID("whisper-large-turbo3",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 2. Load chat model for note generation
// ──────────────────────────────────────
Console.WriteLine("Loading chat model...");
using LM chatModel = LM.LoadFromModelID("qwen3:8b",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 3. Transcribe the meeting
// ──────────────────────────────────────
string audioPath = "meeting.wav";
if (!File.Exists(audioPath))
{
Console.WriteLine($"Place a WAV file at '{audioPath}' and run again.");
return;
}
var stt = new SpeechToText(whisperModel)
{
EnableVoiceActivityDetection = true,
SuppressNonSpeechTokens = true,
SuppressHallucinations = true
};
Console.WriteLine($"Transcribing {audioPath}...");
using var audio = new WaveFile(audioPath);
Console.WriteLine($" Duration: {audio.Duration:mm\\:ss\\.ff}\n");
var transcription = stt.Transcribe(audio);
// Build a timestamped transcript
var timestamped = new StringBuilder();
foreach (var seg in transcription.Segments)
{
timestamped.AppendLine($"[{seg.Start:mm\\:ss}] {seg.Text}");
}
string transcript = transcription.Text;
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"Transcribed {transcription.Segments.Count} segments\n");
Console.ResetColor();
// ──────────────────────────────────────
// 4. Generate executive summary
// ──────────────────────────────────────
Console.WriteLine("Generating summary...\n");
var summarizer = new Summarizer(chatModel)
{
MaxContentWords = 100,
MaxTitleWords = 10,
GenerateTitle = true,
GenerateContent = true,
Intent = Summarizer.SummarizationIntent.Abstraction,
Guidance = "Focus on the main topics discussed, key decisions made, " +
"and outcomes. This is a meeting transcript.",
OverflowStrategy = Summarizer.OverflowResolutionStrategy.RecursiveSummarize
};
Summarizer.SummarizerResult summary = summarizer.Summarize(transcript);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Title: {summary.Title}");
Console.ResetColor();
Console.WriteLine($"Summary: {summary.Summary}\n");
// ──────────────────────────────────────
// 5. Generate structured meeting notes
// ──────────────────────────────────────
Console.WriteLine("Generating meeting notes...\n");
var notesGenerator = new SingleTurnConversation(chatModel)
{
SystemPrompt = "You are a professional meeting notes writer. Convert the provided meeting " +
"transcript into well-structured Markdown meeting notes. Use this format:\n\n" +
"# Meeting Notes: [Meeting Title]\n\n" +
"**Date:** [if mentioned]\n" +
"**Attendees:** [names mentioned in transcript]\n\n" +
"## Executive Summary\n" +
"[2-3 sentence overview]\n\n" +
"## Discussion Topics\n" +
"### [Topic 1 Title]\n" +
"[Summary of discussion]\n\n" +
"### [Topic 2 Title]\n" +
"[Summary of discussion]\n\n" +
"## Decisions Made\n" +
"- [Decision 1]\n" +
"- [Decision 2]\n\n" +
"## Action Items\n" +
"- [ ] [Task] (Owner: [name], Due: [date])\n\n" +
"## Next Steps\n" +
"[Any follow-up meetings or milestones mentioned]\n\n" +
"Rules:\n" +
"1. Only include information present in the transcript.\n" +
"2. Do not invent attendees, dates, or details.\n" +
"3. If information is not available, omit the section.\n" +
"4. Use professional, concise language.\n" +
"5. Output only the Markdown notes.",
MaximumCompletionTokens = 4096
};
var notes = new StringBuilder();
notesGenerator.AfterTextCompletion += (_, e) =>
{
if (e.SegmentType == TextSegmentType.UserVisible)
{
notes.Append(e.Text);
Console.Write(e.Text);
}
};
notesGenerator.Submit($"Generate meeting notes from this transcript:\n\n{transcript}");
Console.WriteLine("\n");
// Save outputs
string outputPath = Path.ChangeExtension(audioPath, ".notes.md");
File.WriteAllText(outputPath, notes.ToString());
File.WriteAllText(Path.ChangeExtension(audioPath, ".transcript.txt"), transcript);
File.WriteAllText(Path.ChangeExtension(audioPath, ".timestamped.txt"), timestamped.ToString());
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine($"Saved meeting notes: {outputPath}");
Console.WriteLine($"Saved transcript: {Path.ChangeExtension(audioPath, ".transcript.txt")}");
Console.WriteLine($"Saved timestamped: {Path.ChangeExtension(audioPath, ".timestamped.txt")}");
Console.ResetColor();
Step 4: Domain-Specific Meeting Templates
Customize the note format for different meeting types:
using System.Text;
using LMKit.Media.Audio;
using LMKit.Model;
using LMKit.Speech;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
LMKit.Licensing.LicenseManager.SetLicenseKey("");
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;
// ──────────────────────────────────────
// 1. Load Whisper model
// ──────────────────────────────────────
Console.WriteLine("Loading Whisper model...");
using LM whisperModel = LM.LoadFromModelID("whisper-large-turbo3",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 2. Load chat model for note generation
// ──────────────────────────────────────
Console.WriteLine("Loading chat model...");
using LM chatModel = LM.LoadFromModelID("qwen3:8b",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 3. Transcribe the meeting
// ──────────────────────────────────────
string audioPath = "meeting.wav";
if (!File.Exists(audioPath))
{
Console.WriteLine($"Place a WAV file at '{audioPath}' and run again.");
return;
}
var stt = new SpeechToText(whisperModel)
{
EnableVoiceActivityDetection = true,
SuppressNonSpeechTokens = true,
SuppressHallucinations = true
};
Console.WriteLine($"Transcribing {audioPath}...");
using var audio = new WaveFile(audioPath);
Console.WriteLine($" Duration: {audio.Duration:mm\\:ss\\.ff}\n");
var transcription = stt.Transcribe(audio);
// Build a timestamped transcript
var timestamped = new StringBuilder();
foreach (var seg in transcription.Segments)
{
timestamped.AppendLine($"[{seg.Start:mm\\:ss}] {seg.Text}");
}
string transcript = transcription.Text;
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"Transcribed {transcription.Segments.Count} segments\n");
Console.ResetColor();
// ──────────────────────────────────────
// 4. Generate executive summary
// ──────────────────────────────────────
Console.WriteLine("Generating summary...\n");
var summarizer = new Summarizer(chatModel)
{
MaxContentWords = 100,
MaxTitleWords = 10,
GenerateTitle = true,
GenerateContent = true,
Intent = Summarizer.SummarizationIntent.Abstraction,
Guidance = "Focus on the main topics discussed, key decisions made, " +
"and outcomes. This is a meeting transcript.",
OverflowStrategy = Summarizer.OverflowResolutionStrategy.RecursiveSummarize
};
Summarizer.SummarizerResult summary = summarizer.Summarize(transcript);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Title: {summary.Title}");
Console.ResetColor();
Console.WriteLine($"Summary: {summary.Summary}\n");
// ──────────────────────────────────────
// 5. Generate structured meeting notes
// ──────────────────────────────────────
Console.WriteLine("Generating meeting notes...\n");
var notesGenerator = new SingleTurnConversation(chatModel)
{
SystemPrompt = "You are a professional meeting notes writer. Convert the provided meeting " +
"transcript into well-structured Markdown meeting notes. Use this format:\n\n" +
"# Meeting Notes: [Meeting Title]\n\n" +
"**Date:** [if mentioned]\n" +
"**Attendees:** [names mentioned in transcript]\n\n" +
"## Executive Summary\n" +
"[2-3 sentence overview]\n\n" +
"## Discussion Topics\n" +
"### [Topic 1 Title]\n" +
"[Summary of discussion]\n\n" +
"### [Topic 2 Title]\n" +
"[Summary of discussion]\n\n" +
"## Decisions Made\n" +
"- [Decision 1]\n" +
"- [Decision 2]\n\n" +
"## Action Items\n" +
"- [ ] [Task] (Owner: [name], Due: [date])\n\n" +
"## Next Steps\n" +
"[Any follow-up meetings or milestones mentioned]\n\n" +
"Rules:\n" +
"1. Only include information present in the transcript.\n" +
"2. Do not invent attendees, dates, or details.\n" +
"3. If information is not available, omit the section.\n" +
"4. Use professional, concise language.\n" +
"5. Output only the Markdown notes.",
MaximumCompletionTokens = 4096
};
var notes = new StringBuilder();
notesGenerator.AfterTextCompletion += (_, e) =>
{
if (e.SegmentType == TextSegmentType.UserVisible)
{
notes.Append(e.Text);
Console.Write(e.Text);
}
};
notesGenerator.Submit($"Generate meeting notes from this transcript:\n\n{transcript}");
Console.WriteLine("\n");
// Sprint retrospective format
var retroGenerator = new SingleTurnConversation(chatModel)
{
SystemPrompt = "Convert this meeting transcript into sprint retrospective notes in Markdown:\n\n" +
"# Sprint Retrospective: [Sprint Name/Number]\n\n" +
"## What Went Well\n- [items]\n\n" +
"## What Needs Improvement\n- [items]\n\n" +
"## Action Items for Next Sprint\n- [ ] [task] (Owner: [name])\n\n" +
"Only include information from the transcript. Use concise language.",
MaximumCompletionTokens = 4096
};
// Board meeting minutes format
var boardGenerator = new SingleTurnConversation(chatModel)
{
SystemPrompt = "Convert this transcript into formal board meeting minutes in Markdown:\n\n" +
"# Board Meeting Minutes\n\n" +
"**Date:** **Location:** **Present:** **Absent:**\n\n" +
"## Call to Order\n\n" +
"## Approval of Previous Minutes\n\n" +
"## Reports\n### [Report Title]\n\n" +
"## Old Business\n\n## New Business\n\n" +
"## Motions and Votes\n" +
"| Motion | Moved By | Seconded | Result |\n|---|---|---|---|\n\n" +
"## Adjournment\n\n" +
"Use formal language. Only include information from the transcript.",
MaximumCompletionTokens = 4096
};
// One-on-one meeting notes
var oneOnOneGenerator = new SingleTurnConversation(chatModel)
{
SystemPrompt = "Convert this transcript into 1:1 meeting notes in Markdown:\n\n" +
"# 1:1 Meeting Notes\n\n" +
"## Updates Since Last Meeting\n- [items]\n\n" +
"## Discussion Points\n- [items]\n\n" +
"## Feedback Given\n- [items]\n\n" +
"## Goals and Priorities\n- [items]\n\n" +
"## Action Items\n- [ ] [task] (Owner: [name])\n\n" +
"Keep notes concise. Only include information from the transcript.",
MaximumCompletionTokens = 4096
};
Step 5: Include Timestamped References
Generate notes that link back to specific moments in the recording:
using System.Text;
using LMKit.Media.Audio;
using LMKit.Model;
using LMKit.Speech;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
LMKit.Licensing.LicenseManager.SetLicenseKey("");
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;
// ──────────────────────────────────────
// 1. Load Whisper model
// ──────────────────────────────────────
Console.WriteLine("Loading Whisper model...");
using LM whisperModel = LM.LoadFromModelID("whisper-large-turbo3",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 2. Load chat model for note generation
// ──────────────────────────────────────
Console.WriteLine("Loading chat model...");
using LM chatModel = LM.LoadFromModelID("qwen3:8b",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 3. Transcribe the meeting
// ──────────────────────────────────────
string audioPath = "meeting.wav";
if (!File.Exists(audioPath))
{
Console.WriteLine($"Place a WAV file at '{audioPath}' and run again.");
return;
}
var stt = new SpeechToText(whisperModel)
{
EnableVoiceActivityDetection = true,
SuppressNonSpeechTokens = true,
SuppressHallucinations = true
};
Console.WriteLine($"Transcribing {audioPath}...");
using var audio = new WaveFile(audioPath);
Console.WriteLine($" Duration: {audio.Duration:mm\\:ss\\.ff}\n");
var transcription = stt.Transcribe(audio);
// Build a timestamped transcript
var timestamped = new StringBuilder();
foreach (var seg in transcription.Segments)
{
timestamped.AppendLine($"[{seg.Start:mm\\:ss}] {seg.Text}");
}
string transcript = transcription.Text;
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"Transcribed {transcription.Segments.Count} segments\n");
Console.ResetColor();
// ──────────────────────────────────────
// 4. Generate executive summary
// ──────────────────────────────────────
Console.WriteLine("Generating summary...\n");
var summarizer = new Summarizer(chatModel)
{
MaxContentWords = 100,
MaxTitleWords = 10,
GenerateTitle = true,
GenerateContent = true,
Intent = Summarizer.SummarizationIntent.Abstraction,
Guidance = "Focus on the main topics discussed, key decisions made, " +
"and outcomes. This is a meeting transcript.",
OverflowStrategy = Summarizer.OverflowResolutionStrategy.RecursiveSummarize
};
Summarizer.SummarizerResult summary = summarizer.Summarize(transcript);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Title: {summary.Title}");
Console.ResetColor();
Console.WriteLine($"Summary: {summary.Summary}\n");
// ──────────────────────────────────────
// 5. Generate structured meeting notes
// ──────────────────────────────────────
Console.WriteLine("Generating meeting notes...\n");
var notesGenerator = new SingleTurnConversation(chatModel)
{
SystemPrompt = "You are a professional meeting notes writer. Convert the provided meeting " +
"transcript into well-structured Markdown meeting notes. Use this format:\n\n" +
"# Meeting Notes: [Meeting Title]\n\n" +
"**Date:** [if mentioned]\n" +
"**Attendees:** [names mentioned in transcript]\n\n" +
"## Executive Summary\n" +
"[2-3 sentence overview]\n\n" +
"## Discussion Topics\n" +
"### [Topic 1 Title]\n" +
"[Summary of discussion]\n\n" +
"### [Topic 2 Title]\n" +
"[Summary of discussion]\n\n" +
"## Decisions Made\n" +
"- [Decision 1]\n" +
"- [Decision 2]\n\n" +
"## Action Items\n" +
"- [ ] [Task] (Owner: [name], Due: [date])\n\n" +
"## Next Steps\n" +
"[Any follow-up meetings or milestones mentioned]\n\n" +
"Rules:\n" +
"1. Only include information present in the transcript.\n" +
"2. Do not invent attendees, dates, or details.\n" +
"3. If information is not available, omit the section.\n" +
"4. Use professional, concise language.\n" +
"5. Output only the Markdown notes.",
MaximumCompletionTokens = 4096
};
var notes = new StringBuilder();
notesGenerator.AfterTextCompletion += (_, e) =>
{
if (e.SegmentType == TextSegmentType.UserVisible)
{
notes.Append(e.Text);
Console.Write(e.Text);
}
};
notesGenerator.Submit($"Generate meeting notes from this transcript:\n\n{transcript}");
Console.WriteLine("\n");
Console.WriteLine("\n=== Meeting Notes with Timestamps ===\n");
var timestampedGenerator = new SingleTurnConversation(chatModel)
{
SystemPrompt = "Convert this timestamped meeting transcript into structured meeting notes " +
"in Markdown. For each major discussion topic and decision, include the " +
"timestamp reference in parentheses (e.g., '(at 05:23)'). This helps " +
"readers jump to the relevant part of the recording.\n\n" +
"Format:\n" +
"# Meeting Notes\n\n" +
"## Discussion Topics\n" +
"### [Topic] (at MM:SS)\n[Summary]\n\n" +
"## Decisions\n- [Decision] (at MM:SS)\n\n" +
"## Action Items\n- [ ] [Task] (Owner, Due date)\n\n" +
"Only include information from the transcript.",
MaximumCompletionTokens = 4096
};
var tsNotes = new StringBuilder();
timestampedGenerator.AfterTextCompletion += (_, e) =>
{
if (e.SegmentType == TextSegmentType.UserVisible)
{
tsNotes.Append(e.Text);
Console.Write(e.Text);
}
};
// Use the timestamped transcript (with [MM:SS] prefixes)
timestampedGenerator.Submit(
$"Generate timestamped meeting notes from this transcript:\n\n{timestamped}");
Console.WriteLine("\n");
File.WriteAllText("meeting_notes_timestamped.md", tsNotes.ToString());
Step 6: Batch Process a Meeting Archive
Process multiple recordings and generate an index:
using System.Text;
using LMKit.Media.Audio;
using LMKit.Model;
using LMKit.Speech;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;
LMKit.Licensing.LicenseManager.SetLicenseKey("");
Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;
// ──────────────────────────────────────
// 1. Load Whisper model
// ──────────────────────────────────────
Console.WriteLine("Loading Whisper model...");
using LM whisperModel = LM.LoadFromModelID("whisper-large-turbo3",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 2. Load chat model for note generation
// ──────────────────────────────────────
Console.WriteLine("Loading chat model...");
using LM chatModel = LM.LoadFromModelID("qwen3:8b",
downloadingProgress: (_, len, read) =>
{
if (len.HasValue) Console.Write($"\r Downloading: {(double)read / len.Value * 100:F1}% ");
return true;
},
loadingProgress: p => { Console.Write($"\r Loading: {p * 100:F0}% "); return true; });
Console.WriteLine("\n");
// ──────────────────────────────────────
// 3. Transcribe the meeting
// ──────────────────────────────────────
string audioPath = "meeting.wav";
if (!File.Exists(audioPath))
{
Console.WriteLine($"Place a WAV file at '{audioPath}' and run again.");
return;
}
var stt = new SpeechToText(whisperModel)
{
EnableVoiceActivityDetection = true,
SuppressNonSpeechTokens = true,
SuppressHallucinations = true
};
Console.WriteLine($"Transcribing {audioPath}...");
using var audio = new WaveFile(audioPath);
Console.WriteLine($" Duration: {audio.Duration:mm\\:ss\\.ff}\n");
var transcription = stt.Transcribe(audio);
// Build a timestamped transcript
var timestamped = new StringBuilder();
foreach (var seg in transcription.Segments)
{
timestamped.AppendLine($"[{seg.Start:mm\\:ss}] {seg.Text}");
}
string transcript = transcription.Text;
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"Transcribed {transcription.Segments.Count} segments\n");
Console.ResetColor();
// ──────────────────────────────────────
// 4. Generate executive summary
// ──────────────────────────────────────
Console.WriteLine("Generating summary...\n");
var summarizer = new Summarizer(chatModel)
{
MaxContentWords = 100,
MaxTitleWords = 10,
GenerateTitle = true,
GenerateContent = true,
Intent = Summarizer.SummarizationIntent.Abstraction,
Guidance = "Focus on the main topics discussed, key decisions made, " +
"and outcomes. This is a meeting transcript.",
OverflowStrategy = Summarizer.OverflowResolutionStrategy.RecursiveSummarize
};
Summarizer.SummarizerResult summary = summarizer.Summarize(transcript);
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($"Title: {summary.Title}");
Console.ResetColor();
Console.WriteLine($"Summary: {summary.Summary}\n");
// ──────────────────────────────────────
// 5. Generate structured meeting notes
// ──────────────────────────────────────
Console.WriteLine("Generating meeting notes...\n");
var notesGenerator = new SingleTurnConversation(chatModel)
{
SystemPrompt = "You are a professional meeting notes writer. Convert the provided meeting " +
"transcript into well-structured Markdown meeting notes. Use this format:\n\n" +
"# Meeting Notes: [Meeting Title]\n\n" +
"**Date:** [if mentioned]\n" +
"**Attendees:** [names mentioned in transcript]\n\n" +
"## Executive Summary\n" +
"[2-3 sentence overview]\n\n" +
"## Discussion Topics\n" +
"### [Topic 1 Title]\n" +
"[Summary of discussion]\n\n" +
"### [Topic 2 Title]\n" +
"[Summary of discussion]\n\n" +
"## Decisions Made\n" +
"- [Decision 1]\n" +
"- [Decision 2]\n\n" +
"## Action Items\n" +
"- [ ] [Task] (Owner: [name], Due: [date])\n\n" +
"## Next Steps\n" +
"[Any follow-up meetings or milestones mentioned]\n\n" +
"Rules:\n" +
"1. Only include information present in the transcript.\n" +
"2. Do not invent attendees, dates, or details.\n" +
"3. If information is not available, omit the section.\n" +
"4. Use professional, concise language.\n" +
"5. Output only the Markdown notes.",
MaximumCompletionTokens = 4096
};
var notes = new StringBuilder();
notesGenerator.AfterTextCompletion += (_, e) =>
{
if (e.SegmentType == TextSegmentType.UserVisible)
{
notes.Append(e.Text);
Console.Write(e.Text);
}
};
notesGenerator.Submit($"Generate meeting notes from this transcript:\n\n{transcript}");
Console.WriteLine("\n");
Console.WriteLine("\n=== Batch Meeting Notes ===\n");
string meetingsDir = "meetings";
string outputDir = "meeting_notes";
if (!Directory.Exists(meetingsDir))
{
Console.WriteLine($"Create a '{meetingsDir}' folder with WAV files, then run again.");
return;
}
Directory.CreateDirectory(outputDir);
string[] wavFiles = Directory.GetFiles(meetingsDir, "*.wav");
Console.WriteLine($"Found {wavFiles.Length} recording(s)\n");
var indexLines = new List<string>();
indexLines.Add("# Meeting Notes Index\n");
indexLines.Add("| File | Title | Summary |");
indexLines.Add("|---|---|---|");
foreach (string wavPath in wavFiles)
{
string meetingName = Path.GetFileNameWithoutExtension(wavPath);
Console.Write($" {Path.GetFileName(wavPath)}: ");
try
{
// Transcribe
using var wav = new WaveFile(wavPath);
var meetingResult = stt.Transcribe(wav);
// Summarize for the index
Summarizer.SummarizerResult meetingSummary = summarizer.Summarize(meetingResult.Text);
// Generate full notes
var meetingNotes = new StringBuilder();
notesGenerator.AfterTextCompletion += (_, e) =>
{
if (e.SegmentType == TextSegmentType.UserVisible)
meetingNotes.Append(e.Text);
};
notesGenerator.Submit(
$"Generate meeting notes from this transcript:\n\n{meetingResult.Text}");
// Save notes
string notesPath = Path.Combine(outputDir, $"{meetingName}.md");
File.WriteAllText(notesPath, meetingNotes.ToString());
// Add to index
string title = (meetingSummary.Title ?? meetingName).Replace("|", "\\|");
string content = (meetingSummary.Summary ?? "").Replace("|", "\\|").Replace("\n", " ");
if (content.Length > 120) content = content[..120] + "...";
indexLines.Add($"| [{meetingName}]({meetingName}.md) | {title} | {content} |");
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine($"done → {notesPath}");
Console.ResetColor();
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"failed: {ex.Message}");
Console.ResetColor();
}
}
// Save index
string indexPath = Path.Combine(outputDir, "INDEX.md");
File.WriteAllLines(indexPath, indexLines);
Console.WriteLine($"\nIndex saved: {indexPath}");
Console.WriteLine($"All notes saved to {Path.GetFullPath(outputDir)}");
Model Selection
Whisper Models (Transcription)
| Model ID | VRAM | Speed | Best For |
|---|---|---|---|
whisper-large-turbo3 |
~870 MB | Moderate | Best accuracy (recommended) |
whisper-small |
~260 MB | Fast | Large meeting backlogs |
Chat Models (Note Generation)
| Model ID | VRAM | Quality | Best For |
|---|---|---|---|
gemma3:4b |
~3.5 GB | Good | Short meetings, simple agendas |
qwen3:8b |
~6 GB | Very good | Complex meetings, multiple topics (recommended) |
gemma3:12b |
~8 GB | Excellent | Dense board meetings, formal minutes |
Common Issues
| Problem | Cause | Fix |
|---|---|---|
| Notes miss topics from second half | Transcript too long for context window | Use RecursiveSummarize overflow strategy; split transcript into chunks |
| Invented attendee names | LLM hallucinated names not in transcript | Strengthen the system prompt: "Only include names explicitly mentioned" |
| Notes too brief | MaximumCompletionTokens too low |
Increase to 4096 or higher |
| Wrong meeting type template | Used generic format for specialized meeting | Use domain-specific templates (Step 4) |
| Timestamps missing from notes | Used plain transcript instead of timestamped version | Pass the timestamped transcript (Step 5) |
Next Steps
- Transcribe Audio with Local Speech-to-Text: foundational transcription guide.
- Extract Action Items and Tasks from Meeting Recordings: focused action item extraction.
- Transcribe and Generate Chaptered Documents from Audio: organize long recordings into titled chapters.
- Summarize Documents and Text: core summarization guide.