Council Meeting Audio Intelligence Tool

Transform council meeting recordings into structured reports and audio briefings.

Introduction

30 Second Summary

Council planning meetings decide what gets built in your neighborhood. They can run for hours, and the important decisions are buried deep in the recording.

In this project, you will build a Python tool that processes a council meeting recording and produces two outputs: a structured report of every planning decision, and a two-voice audio briefing that explains those decisions in plain language.

What You'll Build

A command-line Python tool that chains three APIs together to transform raw meeting audio into structured data and an accessible multi-voice audio summary.

By the end of this project, you'll have:

  • A working transcription module using ElevenLabs Scribe v2 with speaker diarization and domain-specific keyterm prompting.
  • A structured extraction module that uses Azure OpenAI to parse planning decisions, conditions, and policy references from transcripts.
  • A two-voice audio briefing generated by ElevenLabs Text to Dialogue, where a planning officer and a resident discuss the decisions naturally.
  • Secret Mission: A batch processing mode that handles multiple meeting recordings and produces a combined weekly planning digest.

Are there any prerequisites?

Yes. You will need Python 3.11 installed, VS Code installed, and an Azure Foundry subscription with a deployed model. Step 1 walks you through the remaining setup.

Set Up Your Project Environment

Before you can process any audio, you need a workspace with the right tools installed.

In this step, you will create a Python project folder, install the libraries that connect to ElevenLabs and Azure OpenAI, and configure your API credentials so the pipeline can authenticate with both services.

In this step, get ready to:
  • Create a project folder with a virtual environment and install dependencies.
  • Set up an ElevenLabs account with Pay-As-You-Go billing and store all API credentials in a secure environment file.
  • Prepare a sample council meeting audio file for testing.
Create your project folder and install dependencies

Your project needs its own folder and an isolated Python environment so that the packages you install do not conflict with anything else on your machine.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir council-meeting-tool
cd council-meeting-tool
  • Confirm the folder exists by running:
ls

You should see council-meeting-tool listed on your Desktop.

  • Create a virtual environment by running this command:
python3 -m venv venv

What is a virtual environment?

A virtual environment is an isolated space on your computer where Python packages are installed independently of your system-wide Python.

This prevents version conflicts between projects. Every professional Python project uses one.

  • Activate the virtual environment by running the command for your operating system:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On Windows, you may need to allow script execution first. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell, then try the activation command again.

On macOS, make sure you typed source before the path. Without it, the command will not activate the environment.

help me activate my Python virtual environment

Now you will create a requirements.txt file that lists the exact package versions your project needs.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Visual Studio Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop, select the council-meeting-tool folder, and click Open.

Why open the folder in VS Code?

Opening the entire folder (not just a single file) lets VS Code understand your project structure. It also ensures the integrated terminal opens inside your project directory automatically.

  • Create a new file by clicking File then New File.
  • Name the file requirements.txt and press Enter.
  • Paste the following contents into requirements.txt:
elevenlabs==2.54.0
openai==2.44.0
python-dotenv==1.1.0

What do these packages do?

  • elevenlabs is the official Python SDK for ElevenLabs. You will use it for speech-to-text transcription and text-to-dialogue audio generation.
  • openai provides the AzureOpenAI client for calling your Azure-hosted language model.
  • python-dotenv loads environment variables from a .env file so your API keys stay out of your code.
  • Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, below Escape).

If your virtual environment is not active in this new terminal, activate it again using the command for your OS from earlier.

  • Install all three packages by running:
pip install -r requirements.txt

What does this command do?

The -r flag tells pip to read package names and versions from the file. This ensures everyone on the project installs the exact same versions.

You should see pip download and install each package along with its dependencies. The process may take a minute.

  • Verify the ElevenLabs SDK installed correctly by running:
python -c "from elevenlabs.client import ElevenLabs; print('OK')"
  • Verify the OpenAI SDK installed correctly by running:
python -c "from openai import AzureOpenAI; print('OK')"

Both commands should print OK. If either fails, check that your virtual environment is active (you should see (venv) in your prompt).

Seeing a ModuleNotFoundError?

Make sure (venv) appears in your terminal prompt. If it does not, run the activation command for your OS again before retrying pip install -r requirements.txt.

If you see a permissions error, confirm you are using the pip inside the virtual environment, not the system-wide one.

help me fix my Python package installation

Create your ElevenLabs account and configure credentials

Your pipeline authenticates with two external services. ElevenLabs handles transcription and audio generation. Azure OpenAI handles intelligent text extraction.

You will store all credentials in a .env file that Python reads at runtime, keeping secrets out of your source code.

  • Open your browser and navigate to elevenlabs.io.
  • Click Get started free.
  • Register using your email (or sign up with Google, Facebook, or GitHub).
  • If you used email, check your inbox for a verification link from ElevenLabs and click it to confirm your account.

What is Pay-As-You-Go?

ElevenLabs Pay-As-You-Go lets you prepay a small balance ($5 minimum) that is drawn down as you use the API. It unlocks full API access on the free plan with no monthly subscription required.

This project will cost well under $2 in total API usage.

  • Once logged in, click Developers in the left sidebar.
  • Select the Top Up section.
  • Enter $5 (the minimum amount) and add a payment method to confirm.

Your account status changes to Pay As You Go. This unlocks API access for Speech-to-Text and Text to Dialogue.

  • In the left sidebar, click Developers again.
  • Select the API Keys tab.
  • Click Create API Key.
  • Copy the key immediately. You will only see the full key once.

Keep your API key safe

Do not share your ElevenLabs API key or commit it to version control. Anyone with the key can make API calls charged to your account. You will paste it into a local .env file in the next step.

  • Back in VS Code, create a new file called .env in the root of your council-meeting-tool folder.
  • Paste the following into .env, replacing each placeholder with your real values:
ELEVENLABS_API_KEY=your-elevenlabs-api-key-here
AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here
AZURE_OPENAI_ENDPOINT=[[AZURE_ENDPOINT="https://your-resource-name.openai.azure.com"]]
AZURE_OPENAI_DEPLOYMENT=[[AZURE_DEPLOYMENT="your deployment name from Azure Foundry"]]
AZURE_OPENAI_API_VERSION=2025-04-01-preview

Where do I find my Azure credentials?

Open the Azure portal and navigate to your Azure OpenAI resource. Your endpoint is on the Overview page. Your deployment name is under Model Deployments. Your API key is under Keys and Endpoint.

The AZURE_OPENAI_API_VERSION value 2025-04-01-preview is fixed for this project. Do not change it.

  • Replace your-elevenlabs-api-key-here with the key you copied from ElevenLabs.
  • Replace your-azure-openai-api-key-here with your Azure OpenAI key.
  • Save .env by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Not sure if your .env is being read?

Make sure the file is named exactly .env (with the leading dot) and sits in the same folder as your Python scripts. Some operating systems hide files that start with a dot, but VS Code will still show them in the Explorer sidebar.

help me check that my .env file is configured correctly

Prepare a sample council meeting audio file

The pipeline needs an audio file to process. You will create a short recording that simulates a planning committee discussion, or use any publicly available council meeting clip.

  • Record a 1-2 minute audio clip using your computer's voice recorder. Simulate a short planning committee exchange between two or three speakers discussing a fictional planning application.

Keep the recording simple

You do not need a real meeting. A 60-second recording of yourself using different voices to play a "chair" and an "applicant" works well. Mention a few planning terms like "Section 106", "permitted development", or "material considerations" so the keyterm prompting has something to detect.

If you prefer not to record, search for a publicly available UK council planning committee recording on YouTube and download a 1-2 minute clip as an MP3.

  • Save the file as meeting.mp3 inside your council-meeting-tool folder. If your recording is a different format (like .m4a or .wav), rename it to meeting.mp3 or convert it using a free online tool.
  • Verify the file is in the right place by running this in your VS Code terminal:
ls meeting.mp3

You should see meeting.mp3 listed without any "No such file" error.

  • Double-click meeting.mp3 in your file browser (or open it with your default audio player) to confirm it plays correctly. You should hear your recorded voices clearly.

File doesn't play or sounds empty?

Check that the file size is more than a few kilobytes. A 60-second recording at standard quality is typically 500KB-1MB.

If you converted the file, make sure the conversion completed successfully. Try playing the original source file to rule out a recording issue.

help me troubleshoot my audio file for the council meeting tool

✔️ Awesome, I've got everything!

Great. Double check you have saved both requirements.txt and .env, and that meeting.mp3 is in your project folder.

ⓧ I'd like to double check the full code

Here is what your requirements.txt should look like:

elevenlabs==2.54.0
openai==2.44.0
python-dotenv==1.1.0

Here is what your .env should look like (with your real values substituted):

ELEVENLABS_API_KEY=your-elevenlabs-api-key-here
AZURE_OPENAI_API_KEY=your-azure-openai-api-key-here
AZURE_OPENAI_ENDPOINT=https://your-resource-name.openai.azure.com
AZURE_OPENAI_DEPLOYMENT=your-deployment-name-here
AZURE_OPENAI_API_VERSION=2025-04-01-preview

Your environment is fully configured. Next up, you will write the transcription module that turns your council meeting recording into speaker-labelled text using ElevenLabs Scribe v2.

Transcribe the Meeting with Speaker Diarization

Your environment is set up and your sample audio is ready to process. Now it's time to build the first module in your pipeline: transcription.

A raw audio recording of a planning meeting is useless until you can read what was said and know who said it. In this step, you'll use ElevenLabs Scribe v2 to transcribe your meeting audio with speaker diarization and domain-specific keyterm prompting.

In this step, get ready to:
  • Write a transcription script that calls ElevenLabs Scribe v2 with speaker diarization and planning keyterms.
  • Parse the word-level response into speaker-labelled segments and save them to JSON.
  • Run the script against your sample audio and verify the output.
Write the transcription call with Scribe v2

ElevenLabs Scribe v2 is a speech-to-text model that supports up to 48 speakers via diarization. You'll also supply planning-specific keyterms so the model correctly transcribes domain vocabulary like "Section 106" and "NPPF" instead of guessing at unfamiliar words.

  • In VS Code, create a new file called transcribe.py in your project folder.
  • Add the imports and ElevenLabs client setup by pasting this code:
import os
import json

from dotenv import load_dotenv
from elevenlabs.client import ElevenLabs

# Load environment variables from .env
load_dotenv()

# Create the ElevenLabs client with your API key
client = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY"))

What does this code do?

load_dotenv() reads the .env file and makes its values available via os.getenv().

The ElevenLabs client handles authentication and gives you access to all ElevenLabs API endpoints, including speech_to_text.

  • Below the client setup, add the transcribe_meeting function that calls Scribe v2 with diarization and keyterms:

def transcribe_meeting(audio_path: str, output_path: str = "transcript.json") -> dict:
    """Transcribe a council meeting audio file with speaker diarization."""
    with open(audio_path, "rb") as audio_file:
        # Call Scribe v2 with diarization and planning-specific keyterms
        transcript = client.speech_to_text.convert(
            file=audio_file,
            model_id="scribe_v2",
            diarize=True,
            language_code="eng",
            tag_audio_events=True,
            keyterms=[
                "Section 106",
                "permitted development",
                "Use Classes Order",
                "CIL",
                "material considerations",
                "NPPF",
                "planning committee",
                "full planning permission",
                "outline permission",
                "reserved matters",
                "condition",
                "deferral",
            ],
        )

What do these parameters do?

  • model_id="scribe_v2" selects ElevenLabs' latest speech-to-text model.
  • diarize=True tells the model to identify and label each speaker in the audio. Scribe v2 supports up to 48 speakers.
  • language_code="eng" sets the language explicitly instead of relying on auto-detection.
  • tag_audio_events=True annotates non-speech events like laughter or applause in the transcript.
  • keyterms provides domain-specific vocabulary so the model transcribes planning jargon accurately instead of guessing phonetically similar words.

You won't see a change yet. The next chunk adds the parsing logic that processes the response.

Parse the response into speaker segments

Scribe v2 returns a word-level response where each word has a speaker_id and timestamp. You need to group consecutive words from the same speaker into readable segments.

  • Directly below the API call (still inside the transcribe_meeting function), add the parsing logic, file save, and the script entry point:

    # Build segments by grouping consecutive words from the same speaker
    result = {
        "language_code": transcript.language_code,
        "segments": [],
    }

    for word in transcript.words:
        if not result["segments"] or result["segments"][-1]["speaker"] != word.speaker_id:
            # New speaker detected, start a new segment
            result["segments"].append(
                {
                    "speaker": word.speaker_id,
                    "text": word.text,
                    "start": word.start,
                    "end": word.end,
                }
            )
        else:
            # Same speaker, append text to current segment
            result["segments"][-1]["text"] += word.text
            result["segments"][-1]["end"] = word.end

    # Save the structured transcript to a JSON file
    with open(output_path, "w") as f:
        json.dump(result, f, indent=2)

    print(f"Transcription complete: {len(result['segments'])} segments saved to {output_path}")
    return result


if __name__ == "__main__":
    import sys

    audio_file = sys.argv[1] if len(sys.argv) > 1 else "meeting.mp3"
    transcribe_meeting(audio_file)

What does the parsing logic do?

The API returns individual words, each tagged with a speaker_id. The for loop walks through every word and groups consecutive words from the same speaker into a single segment.

When the speaker_id changes, a new segment starts. This converts hundreds of word-level entries into a readable list of speaker turns with start and end timestamps.

The if __name__ == "__main__" block lets you run the script directly from the terminal. It defaults to meeting.mp3 if you don't pass a filename argument.

  • Save transcribe.py.

Seeing a syntax error when you save?

  • Make sure the parsing code is indented inside the transcribe_meeting function body (one level of indentation, aligned with the with open block above it).
  • Check that the if __name__ block is at the top level (no indentation), outside the function.
  • help me fix a syntax error in my transcribe.py file

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

import os
import json

from dotenv import load_dotenv
from elevenlabs.client import ElevenLabs

load_dotenv()

client = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY"))


def transcribe_meeting(audio_path: str, output_path: str = "transcript.json") -> dict:
    """Transcribe a council meeting audio file with speaker diarization."""
    with open(audio_path, "rb") as audio_file:
        transcript = client.speech_to_text.convert(
            file=audio_file,
            model_id="scribe_v2",
            diarize=True,
            language_code="eng",
            tag_audio_events=True,
            keyterms=[
                "Section 106",
                "permitted development",
                "Use Classes Order",
                "CIL",
                "material considerations",
                "NPPF",
                "planning committee",
                "full planning permission",
                "outline permission",
                "reserved matters",
                "condition",
                "deferral",
            ],
        )

    result = {
        "language_code": transcript.language_code,
        "segments": [],
    }

    for word in transcript.words:
        if not result["segments"] or result["segments"][-1]["speaker"] != word.speaker_id:
            result["segments"].append(
                {
                    "speaker": word.speaker_id,
                    "text": word.text,
                    "start": word.start,
                    "end": word.end,
                }
            )
        else:
            result["segments"][-1]["text"] += word.text
            result["segments"][-1]["end"] = word.end

    with open(output_path, "w") as f:
        json.dump(result, f, indent=2)

    print(f"Transcription complete: {len(result['segments'])} segments saved to {output_path}")
    return result


if __name__ == "__main__":
    import sys

    audio_file = sys.argv[1] if len(sys.argv) > 1 else "meeting.mp3"
    transcribe_meeting(audio_file)
Run the transcription script
  • Run the script against your sample audio by running this command in your terminal:
python transcribe.py meeting.mp3

What should I see?

The script will take a few seconds to upload your audio and receive the transcription. When complete, you should see a message like:

Transcription complete: 12 segments saved to transcript.json

The exact number of segments depends on how many speaker changes are in your audio file.

  • Confirm the output file was created by running:
ls transcript.json
  • You should see transcript.json in the output.
  • Open transcript.json in VS Code and verify it contains a language_code field and a segments array where each segment has speaker, text, start, and end fields.
  • Check that planning keyterms like "Section 106" or "NPPF" appear correctly in the transcript text (if your sample audio included those terms).

Getting an authentication error?

  • Make sure your .env file contains ELEVENLABS_API_KEY with a valid key. Check for extra spaces or quotation marks around the value.
  • Verify your ElevenLabs account has Pay-As-You-Go enabled. The free tier without PAYG may not provide full API access.
  • If you see a file not found error, confirm meeting.mp3 is in the same folder as transcribe.py.
  • help me debug an error when running transcribe.py

Your meeting audio is now structured text with speaker labels. Next up, you'll feed this transcript to Azure OpenAI to extract the actual planning decisions buried in the discussion.

Extract Planning Decisions with Azure OpenAI

You have a transcript full of speaker-labelled text. It captures what people said, but not what was decided.

In this step, you will use Azure OpenAI to read through that transcript and extract structured planning data. The model will identify each application discussed, its decision, any conditions imposed, and policy references cited.

In this step, get ready to:
  • Write a system prompt that instructs the model to extract structured planning data.
  • Call Azure OpenAI with the transcript and parse the response into a structured JSON schema.
  • Save the decision report to a JSON file and print a summary to the terminal.
Set up the extraction script and client

The extraction module connects to your Azure OpenAI deployment and sends the transcript with a carefully designed system prompt. The model returns structured JSON that your code can parse directly.

  • Create a new file called extract.py in your project folder.
  • Add the imports and Azure OpenAI client configuration by pasting this code:
import os
import json

from dotenv import load_dotenv
from openai import AzureOpenAI

load_dotenv()

client = AzureOpenAI(
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
)

What does this code do?

This loads the three Azure credentials from your .env file and creates an AzureOpenAI client. The client handles authentication and routing to your specific Azure deployment.

Unlike the standard OpenAI client, the Azure version needs an explicit endpoint and API version to target your Foundry subscription.

You won't see a change yet. The next chunk defines the system prompt that guides the extraction.

The system prompt is the most important piece. It tells the model exactly what structure to return and what fields to extract from the transcript.

  • Below the client setup in extract.py, add the system prompt constant:
SYSTEM_PROMPT = """You are a planning decisions analyst for a UK local council.
Given a transcript of a planning committee meeting, extract all planning applications discussed.

For each application, provide:
- reference: The application reference number (e.g., "APP/2024/0123") or a brief description if no reference is stated
- proposal: A brief description of what is being proposed
- decision: One of "approved", "refused", or "deferred"
- conditions: A list of any conditions imposed (empty list if none)
- policy_references: A list of any planning policies cited (e.g., "NPPF Para 11", "Local Plan Policy H1")
- key_discussion_points: A list of the main points raised during discussion

Respond with valid JSON in this format:
{
  "meeting_summary": "Brief one-sentence summary of the meeting",
  "applications": [
    {
      "reference": "...",
      "proposal": "...",
      "decision": "...",
      "conditions": ["..."],
      "policy_references": ["..."],
      "key_discussion_points": ["..."]
    }
  ]
}"""

Why is the prompt so specific?

The prompt defines the exact JSON schema the model must return. By specifying field names, allowed values (like "approved", "refused", "deferred"), and examples, you reduce hallucination and get consistent output your code can parse reliably.

Setting temperature=0.1 later in the API call reinforces this. Low temperature makes the model stick closely to the requested format rather than getting creative.

You won't see a change yet. The next substep adds the function that uses this prompt to call Azure OpenAI.

Build the extraction function

The extraction function reads your transcript, formats each speaker segment into a readable string, sends it to Azure OpenAI, and parses the structured response.

  • Below the SYSTEM_PROMPT constant in extract.py, add the extraction function and main block:
def extract_decisions(transcript_path: str = "transcript.json", output_path: str = "decisions.json") -> dict:
    """Extract structured planning decisions from a meeting transcript."""
    with open(transcript_path, "r") as f:
        transcript = json.load(f)

    # Format each speaker segment into a readable transcript string
    formatted_transcript = ""
    for segment in transcript["segments"]:
        speaker = segment["speaker"] or "Unknown"
        formatted_transcript += f"[{speaker}]: {segment['text']}\n"

    # Call Azure OpenAI with the formatted transcript
    response = client.chat.completions.create(
        model=os.getenv("AZURE_OPENAI_DEPLOYMENT"),
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": formatted_transcript},
        ],
        temperature=0.1,
        response_format={"type": "json_object"},
    )

    # Parse the JSON response from the model
    decisions = json.loads(response.choices[0].message.content)

    # Save the structured decisions to a JSON file
    with open(output_path, "w") as f:
        json.dump(decisions, f, indent=2)

    # Print a summary of extracted applications
    app_count = len(decisions.get("applications", []))
    print(f"Extraction complete: {app_count} applications found, saved to {output_path}")

    for app in decisions.get("applications", []):
        print(f"  - {app['reference']}: {app['decision'].upper()} - {app['proposal']}")

    return decisions


if __name__ == "__main__":
    extract_decisions()

What does this function do?

  • It reads transcript.json and formats each segment into a labelled string like [speaker_0]: Good evening... so the model can see who said what.
  • response_format={"type": "json_object"} tells Azure OpenAI to return valid JSON. This prevents the model from wrapping its answer in markdown or adding commentary.
  • After parsing the response, the function saves the structured decisions to decisions.json and prints a per-application summary showing reference, decision, and proposal.
  • Save extract.py.

Seeing a syntax error when you save?

Check that the triple-quoted SYSTEM_PROMPT string ends with three double quotes (""") on the same line as the closing brace. Missing or misaligned quotes are the most common cause of syntax errors in multi-line strings.

help me fix a syntax error in my extract.py system prompt

Run the extraction and verify output
  • Run the extraction script against your transcript by running this command:
python extract.py
  • You should see output listing the number of applications found and a per-application summary with references and decisions.
  • Confirm that decisions.json was created by running:
ls
  • You should see decisions.json listed alongside your other project files.

What should I see in decisions.json?

The file contains a meeting_summary string and an applications array. Each application object includes reference, proposal, decision, conditions, policy_references, and key_discussion_points.

The exact content depends on your meeting audio. If you used a short mock recording, you might see one or two applications. A longer real recording will produce more.

Getting an authentication or deployment error?

Double check your .env file. Make sure AZURE_OPENAI_ENDPOINT includes the full URL with https:// and that AZURE_OPENAI_DEPLOYMENT matches the exact name of your deployed model in Azure Foundry.

If you see a model not found error, confirm your deployment is active in the Azure portal and that the API version 2025-04-01-preview is supported by your resource.

help me debug my Azure OpenAI connection in extract.py

✔️ Awesome, I've got everything!

Great. Double check you've saved extract.py and that decisions.json exists in your project folder.

ⓧ I'd like to double check the full code

import os
import json

from dotenv import load_dotenv
from openai import AzureOpenAI

load_dotenv()

client = AzureOpenAI(
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
)

SYSTEM_PROMPT = """You are a planning decisions analyst for a UK local council.
Given a transcript of a planning committee meeting, extract all planning applications discussed.

For each application, provide:
- reference: The application reference number (e.g., "APP/2024/0123") or a brief description if no reference is stated
- proposal: A brief description of what is being proposed
- decision: One of "approved", "refused", or "deferred"
- conditions: A list of any conditions imposed (empty list if none)
- policy_references: A list of any planning policies cited (e.g., "NPPF Para 11", "Local Plan Policy H1")
- key_discussion_points: A list of the main points raised during discussion

Respond with valid JSON in this format:
{
  "meeting_summary": "Brief one-sentence summary of the meeting",
  "applications": [
    {
      "reference": "...",
      "proposal": "...",
      "decision": "...",
      "conditions": ["..."],
      "policy_references": ["..."],
      "key_discussion_points": ["..."]
    }
  ]
}"""


def extract_decisions(transcript_path: str = "transcript.json", output_path: str = "decisions.json") -> dict:
    """Extract structured planning decisions from a meeting transcript."""
    with open(transcript_path, "r") as f:
        transcript = json.load(f)

    formatted_transcript = ""
    for segment in transcript["segments"]:
        speaker = segment["speaker"] or "Unknown"
        formatted_transcript += f"[{speaker}]: {segment['text']}\n"

    response = client.chat.completions.create(
        model=os.getenv("AZURE_OPENAI_DEPLOYMENT"),
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": formatted_transcript},
        ],
        temperature=0.1,
        response_format={"type": "json_object"},
    )

    decisions = json.loads(response.choices[0].message.content)

    with open(output_path, "w") as f:
        json.dump(decisions, f, indent=2)

    app_count = len(decisions.get("applications", []))
    print(f"Extraction complete: {app_count} applications found, saved to {output_path}")

    for app in decisions.get("applications", []):
        print(f"  - {app['reference']}: {app['decision'].upper()} - {app['proposal']}")

    return decisions


if __name__ == "__main__":
    extract_decisions()

You now have raw audio turned into structured, queryable data. Next up, you will turn those decisions into an accessible two-voice audio briefing that residents can listen to.

Generate the Audio Briefing with Text to Dialogue

Your pipeline can now transcribe meetings and extract structured decisions. The missing piece is making those decisions accessible to residents who cannot attend meetings or read lengthy documents.

In this step, you will use Azure OpenAI to write a natural dialogue script from your decisions, then pass that script to the ElevenLabs Text to Dialogue API to generate a two-voice audio briefing.

In this step, get ready to:
  • Write a dialogue generation prompt and call Azure OpenAI to create a conversational script.
  • Call the ElevenLabs Text to Dialogue API with audio tags for expressive delivery.
  • Save and play the resulting two-voice audio briefing.
Write the dialogue prompt and client setup

Text to Dialogue is ElevenLabs' multi-speaker conversation generation API. It takes a list of dialogue turns, each with a text line and a voice ID, and returns natural-sounding audio where each speaker has a distinct voice.

The API has a 2,000 character limit across all inputs. Your prompt must instruct Azure OpenAI to keep the generated dialogue short enough to fit.

  • Create a new file called briefing.py in your project folder.
  • Add the imports, client setup, voice ID constants, and dialogue prompt by pasting the following code:
import os
import json

from dotenv import load_dotenv
from elevenlabs.client import ElevenLabs
from openai import AzureOpenAI

load_dotenv()

elevenlabs_client = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY"))
openai_client = AzureOpenAI(
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
)

OFFICER_VOICE_ID = "JBFqnCBsd6RMkjVDRZzb"
RESIDENT_VOICE_ID = "9BWtsMINqrJLrRacOk9x"

DIALOGUE_PROMPT = """You are writing a short, accessible audio briefing about planning committee decisions.
Write a natural dialogue between two people:
1. A "Planning Officer" who summarises decisions clearly
2. A "Resident" who asks the kinds of questions a local person would ask

Rules:
- Keep the TOTAL text under 1800 characters (this is a hard limit)
- Use 4-8 dialogue turns total
- The officer should be clear and professional
- The resident should ask practical questions (e.g., "What does that mean for traffic?", "When does building start?")
- Do NOT include speaker labels or quotation marks
- Return ONLY a JSON array of objects with "speaker" (either "officer" or "resident") and "text" fields

Example format:
[
  {"speaker": "officer", "text": "Good afternoon. Let me walk you through this week's planning decisions."},
  {"speaker": "resident", "text": "Yes please, I heard there was a big application for the high street?"}
]"""

What does this code do?

This file uses two API clients. The elevenlabs_client handles audio generation, while openai_client handles dialogue script writing.

The two voice ID constants map to specific voices in the ElevenLabs voice library: JBFqnCBsd6RMkjVDRZzb is "George" (the planning officer) and 9BWtsMINqrJLrRacOk9x is "Aria" (the resident).

The DIALOGUE_PROMPT instructs the LLM to produce a JSON array of dialogue turns. The 1,800 character cap leaves buffer room below the API's 2,000 character hard limit.

Generate the dialogue script from decisions

The generate_briefing function reads decisions.json, sends it to Azure OpenAI with the dialogue prompt, and parses the returned JSON into a list of speaker turns.

  • Below the DIALOGUE_PROMPT constant, add the function definition and Azure OpenAI call:
def generate_briefing(decisions_path: str = "decisions.json", output_path: str = "briefing.mp3") -> str:
    """Generate a two-voice audio briefing from planning decisions."""
    with open(decisions_path, "r") as f:
        decisions = json.load(f)

    response = openai_client.chat.completions.create(
        model=os.getenv("AZURE_OPENAI_DEPLOYMENT"),
        messages=[
            {"role": "system", "content": DIALOGUE_PROMPT},
            {"role": "user", "content": json.dumps(decisions, indent=2)},
        ],
        temperature=0.7,
        response_format={"type": "json_object"},
    )

    raw_response = json.loads(response.choices[0].message.content)
    if isinstance(raw_response, dict) and "dialogue" in raw_response:
        dialogue_script = raw_response["dialogue"]
    elif isinstance(raw_response, list):
        dialogue_script = raw_response
    else:
        dialogue_script = list(raw_response.values())[0]

Why the response parsing logic?

LLMs sometimes wrap the requested JSON array in a top-level object (e.g., {"dialogue": [...]}). The three-branch check handles every common output shape: a dict with a dialogue key, a bare list, or any other dict structure.

A higher temperature of 0.7 gives the dialogue creative variation. The response_format parameter ensures the model returns valid JSON regardless of creativity.

Call the Text to Dialogue API with audio tags

ElevenLabs Text to Dialogue accepts a list of inputs, each with a text field and a voice_id. Audio tags are natural-language instructions wrapped in square brackets (like [professionally] or [curiously]) placed inline in the text to guide delivery style.

  • Directly below the response-parsing code you just added (still inside the generate_briefing function), add the voice mapping, audio tag injection, and API call:
    voice_map = {
        "officer": OFFICER_VOICE_ID,
        "resident": RESIDENT_VOICE_ID,
    }

    inputs = []
    for turn in dialogue_script:
        speaker = turn["speaker"]
        text = turn["text"]
        voice_id = voice_map[speaker]

        if speaker == "officer":
            text = f"[professionally] {text}"
        else:
            text = f"[curiously] {text}"

        inputs.append({"text": text, "voice_id": voice_id})

    audio = elevenlabs_client.text_to_dialogue.convert(inputs=inputs)

What does this code do?

The loop maps each dialogue turn to its assigned voice ID and prepends an audio tag. The officer speaks [professionally] and the resident speaks [curiously]. These tags tell the Eleven v3 model to adjust tone and prosody for each turn.

The text_to_dialogue.convert method sends the complete inputs list to ElevenLabs and returns streamed audio chunks.

Why Text to Dialogue instead of regular TTS?

Regular text-to-speech generates single-speaker output one call at a time. Text to Dialogue generates a full multi-speaker conversation in one request, with natural turn-taking prosody and conversational rhythm between speakers.

This makes it ideal for accessible briefings where two voices discussing decisions feels more engaging than a single narrator reading a list.

  • Still inside the generate_briefing function, add the code to save the audio and return the output path. Then add the __main__ block below the function:
    with open(output_path, "wb") as f:
        for chunk in audio:
            f.write(chunk)

    print(f"Audio briefing saved to {output_path}")
    return output_path


if __name__ == "__main__":
    generate_briefing()

What does this code do?

The API returns audio as an iterator of binary chunks. Writing each chunk to the file builds the complete MP3 without loading the entire response into memory at once.

The __main__ guard lets you run briefing.py directly while also allowing other scripts to import generate_briefing as a module.

  • Save briefing.py.

✔️ Awesome, I've got everything!

Great. Double check you have saved briefing.py before running it.

ⓧ I'd like to double check the full code

import os
import json

from dotenv import load_dotenv
from elevenlabs.client import ElevenLabs
from openai import AzureOpenAI

load_dotenv()

elevenlabs_client = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY"))
openai_client = AzureOpenAI(
    api_key=os.getenv("AZURE_OPENAI_API_KEY"),
    azure_endpoint=os.getenv("AZURE_OPENAI_ENDPOINT"),
    api_version=os.getenv("AZURE_OPENAI_API_VERSION"),
)

OFFICER_VOICE_ID = "JBFqnCBsd6RMkjVDRZzb"
RESIDENT_VOICE_ID = "9BWtsMINqrJLrRacOk9x"

DIALOGUE_PROMPT = """You are writing a short, accessible audio briefing about planning committee decisions.
Write a natural dialogue between two people:
1. A "Planning Officer" who summarises decisions clearly
2. A "Resident" who asks the kinds of questions a local person would ask

Rules:
- Keep the TOTAL text under 1800 characters (this is a hard limit)
- Use 4-8 dialogue turns total
- The officer should be clear and professional
- The resident should ask practical questions (e.g., "What does that mean for traffic?", "When does building start?")
- Do NOT include speaker labels or quotation marks
- Return ONLY a JSON array of objects with "speaker" (either "officer" or "resident") and "text" fields

Example format:
[
  {"speaker": "officer", "text": "Good afternoon. Let me walk you through this week's planning decisions."},
  {"speaker": "resident", "text": "Yes please, I heard there was a big application for the high street?"}
]"""


def generate_briefing(decisions_path: str = "decisions.json", output_path: str = "briefing.mp3") -> str:
    """Generate a two-voice audio briefing from planning decisions."""
    with open(decisions_path, "r") as f:
        decisions = json.load(f)

    response = openai_client.chat.completions.create(
        model=os.getenv("AZURE_OPENAI_DEPLOYMENT"),
        messages=[
            {"role": "system", "content": DIALOGUE_PROMPT},
            {"role": "user", "content": json.dumps(decisions, indent=2)},
        ],
        temperature=0.7,
        response_format={"type": "json_object"},
    )

    raw_response = json.loads(response.choices[0].message.content)
    if isinstance(raw_response, dict) and "dialogue" in raw_response:
        dialogue_script = raw_response["dialogue"]
    elif isinstance(raw_response, list):
        dialogue_script = raw_response
    else:
        dialogue_script = list(raw_response.values())[0]

    voice_map = {
        "officer": OFFICER_VOICE_ID,
        "resident": RESIDENT_VOICE_ID,
    }

    inputs = []
    for turn in dialogue_script:
        speaker = turn["speaker"]
        text = turn["text"]
        voice_id = voice_map[speaker]

        if speaker == "officer":
            text = f"[professionally] {text}"
        else:
            text = f"[curiously] {text}"

        inputs.append({"text": text, "voice_id": voice_id})

    audio = elevenlabs_client.text_to_dialogue.convert(inputs=inputs)

    with open(output_path, "wb") as f:
        for chunk in audio:
            f.write(chunk)

    print(f"Audio briefing saved to {output_path}")
    return output_path


if __name__ == "__main__":
    generate_briefing()
Run the script and verify the audio
  • Run briefing.py by entering this command in your terminal:
python briefing.py
  • You should see output like Audio briefing saved to briefing.mp3 in the terminal.
  • Confirm the file was created by running:
ls briefing.mp3
  • You should see briefing.mp3 listed in the output.
  • Open briefing.mp3 in your system audio player to listen. You should hear two distinct voices: one speaking professionally about the planning decisions and another asking clarifying questions in a curious tone.

Not hearing two voices?

  • Check that your ELEVENLABS_API_KEY in .env is correct and that Pay-As-You-Go is enabled on your account.
  • If you see a character-limit error, open decisions.json and check its size. A very long transcript may produce a dialogue that exceeds 2,000 characters. Try re-running extract.py with a shorter audio file.
  • If the file is 0 bytes, the API call likely failed silently. Add a print(inputs) line before the text_to_dialogue.convert call to inspect what is being sent.

Why isn't my Text to Dialogue call producing audio?

Your tool now transforms raw meeting audio into both a structured report and an accessible audio briefing. Next, you will wire all three modules together into a single pipeline command.

Orchestrate the Full Pipeline

You now have three standalone modules that each handle one piece of the puzzle: transcription, extraction, and audio briefing generation.

Running them one at a time works, but the real power comes from chaining them into a single command. In this step, you will write an orchestrator that runs all three stages in sequence, reports progress, and gracefully handles failures at any stage.

In this step, get ready to:
  • Write pipeline.py to chain transcription, extraction, and briefing generation into one command.
  • Add error handling and progress logging so each stage reports its status and elapsed time.
  • Run the full pipeline end-to-end and verify both output files are produced.
Write the pipeline orchestrator

Each of your three modules exposes a function that can be imported and called directly. The orchestrator imports these functions, runs them in sequence, and wraps each call in error handling so a failure at one stage stops the pipeline with a clear message.

  • Create a new file called pipeline.py in your project folder and paste this code:
import sys
import time

from transcribe import transcribe_meeting
from extract import extract_decisions
from briefing import generate_briefing


def run_pipeline(audio_path: str) -> None:
    """Run the full planning meeting intelligence pipeline."""
    print("=" * 60)
    print("Council Planning Meeting Audio Intelligence Tool")
    print("=" * 60)

    print(f"\nInput: {audio_path}")
    print("-" * 60)

    # Stage 1: Transcribe the audio with speaker diarization
    print("\n[1/3] Transcribing meeting audio...")
    start = time.time()
    try:
        transcript = transcribe_meeting(audio_path)
    except Exception as e:
        print(f"ERROR: Transcription failed - {e}")
        sys.exit(1)
    print(f"      Completed in {time.time() - start:.1f}s")

    # Stage 2: Extract structured planning decisions
    print("\n[2/3] Extracting planning decisions...")
    start = time.time()
    try:
        decisions = extract_decisions()
    except Exception as e:
        print(f"ERROR: Extraction failed - {e}")
        sys.exit(1)
    print(f"      Completed in {time.time() - start:.1f}s")

    # Stage 3: Generate the two-voice audio briefing
    print("\n[3/3] Generating audio briefing...")
    start = time.time()
    try:
        briefing_path = generate_briefing()
    except Exception as e:
        print(f"ERROR: Briefing generation failed - {e}")
        sys.exit(1)
    print(f"      Completed in {time.time() - start:.1f}s")

    # Print final summary
    print("\n" + "=" * 60)
    print("Pipeline complete!")
    print(f"  Decision report: decisions.json")
    print(f"  Audio briefing:  {briefing_path}")
    print("=" * 60)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python pipeline.py <audio_file.mp3>")
        sys.exit(1)

    run_pipeline(sys.argv[1])

What does this code do?

The imports at the top pull in the functions you already wrote: transcribe_meeting from transcribe.py, extract_decisions from extract.py, and generate_briefing from briefing.py.

The run_pipeline function calls each stage in sequence. Each stage is wrapped in a try/except block so that if any API call fails, the pipeline stops immediately with a clear error message instead of crashing with a traceback.

The time.time() calls before and after each stage give you visibility into how long each API call takes. This is especially useful because the ElevenLabs and Azure OpenAI calls can vary in speed depending on audio length and server load.

The __main__ block checks that the learner passed an audio file path as a command-line argument, then kicks off the pipeline.

  • Save pipeline.py.

Seeing a syntax error on save?

  • Check that the f-strings all have matching curly braces.
  • Make sure the from transcribe import transcribe_meeting lines use the exact module names (without the .py extension).
  • Verify that each try: has a matching except Exception as e: at the same indentation level.

Help me debug a syntax error in my pipeline.py file

Why chain modules instead of copying code?

Importing functions from separate files keeps each module independently testable. If the transcription logic changes, you update transcribe.py and the pipeline picks up the changes automatically.

This pattern of small, composable modules is common in production API orchestration pipelines.

✔️ Awesome, I've got everything!

Great. Double check you have saved pipeline.py before moving on.

ⓧ I'd like to double check the full code

import sys
import time

from transcribe import transcribe_meeting
from extract import extract_decisions
from briefing import generate_briefing


def run_pipeline(audio_path: str) -> None:
    """Run the full planning meeting intelligence pipeline."""
    print("=" * 60)
    print("Council Planning Meeting Audio Intelligence Tool")
    print("=" * 60)

    print(f"\nInput: {audio_path}")
    print("-" * 60)

    print("\n[1/3] Transcribing meeting audio...")
    start = time.time()
    try:
        transcript = transcribe_meeting(audio_path)
    except Exception as e:
        print(f"ERROR: Transcription failed - {e}")
        sys.exit(1)
    print(f"      Completed in {time.time() - start:.1f}s")

    print("\n[2/3] Extracting planning decisions...")
    start = time.time()
    try:
        decisions = extract_decisions()
    except Exception as e:
        print(f"ERROR: Extraction failed - {e}")
        sys.exit(1)
    print(f"      Completed in {time.time() - start:.1f}s")

    print("\n[3/3] Generating audio briefing...")
    start = time.time()
    try:
        briefing_path = generate_briefing()
    except Exception as e:
        print(f"ERROR: Briefing generation failed - {e}")
        sys.exit(1)
    print(f"      Completed in {time.time() - start:.1f}s")

    print("\n" + "=" * 60)
    print("Pipeline complete!")
    print(f"  Decision report: decisions.json")
    print(f"  Audio briefing:  {briefing_path}")
    print("=" * 60)


if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: python pipeline.py <audio_file.mp3>")
        sys.exit(1)

    run_pipeline(sys.argv[1])
Run the full pipeline end-to-end

Time to see the whole system in action. One command will transcribe your meeting audio, extract planning decisions, and generate the two-voice briefing.

  • Run the pipeline against your sample audio by running this command:
python pipeline.py meeting.mp3

What should I see?

The terminal prints a banner, then logs each stage as it runs. You will see progress markers like [1/3] Transcribing meeting audio... followed by the elapsed time once that stage finishes.

After all three stages complete, a summary prints the paths to your two output files: decisions.json and briefing.mp3.

  • Confirm both output files were produced by running:
ls decisions.json briefing.mp3
  • You should see both files listed in the output.
  • Play briefing.mp3 to confirm it contains a two-voice conversation about the planning decisions.

Pipeline failing at one of the stages?

  • If stage 1 fails, check that meeting.mp3 exists in the same folder as pipeline.py and that your ELEVENLABS_API_KEY in .env is correct.
  • If stage 2 fails, verify your AZURE_OPENAI_ENDPOINT and AZURE_OPENAI_DEPLOYMENT values in .env match your Azure Foundry deployment.
  • If stage 3 fails with a character limit error, the dialogue script exceeded 2,000 characters. Run extract.py again. A shorter transcript produces a shorter decisions file, which leads to a shorter dialogue.

Help me debug my pipeline.py run

Secret mission

Add Batch Processing Mode

Your pipeline handles one meeting at a time. What if you need to process an entire week's worth of committee recordings and produce a single combined digest? In this secret mission, you'll add a batch mode that processes a directory of audio files, aggregates all decisions into a weekly digest, and generates one combined audio briefing.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on your local machine. The APIs only charge when you actively run your scripts, so there are no ongoing costs while your project folder sits idle.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Project folder on your Desktop (Python scripts, virtual environment, output files, and .env credentials).
  • ElevenLabs account with Pay-As-You-Go enabled.
  • ElevenLabs API key stored in your .env file.
  • Azure OpenAI deployment (pre-existing from your Foundry subscription).

Keep everything running

No action needed. Choose this if you plan to keep experimenting with the pipeline or processing more meeting recordings.

  • Your project folder, virtual environment, and output files remain on your Desktop.
  • Your ElevenLabs account stays active with Pay-As-You-Go enabled. You will only be charged if you run the scripts again.
  • Your Azure OpenAI deployment continues to exist under your Foundry subscription.

Pause - I'll come back to this later

Free up system resources while keeping your files intact so you can pick up where you left off.

  • Deactivate the virtual environment (if active) by running:
  • Close VS Code to free memory.
  • Your project folder remains on your Desktop for when you return.
  • No charges accrue while the scripts are not running.
deactivate

Delete - I don't want to use this again

Remove all project resources and revoke API access. This is permanent.

  • Delete your project folder by running these commands in your terminal:
cd ~/Desktop
rm -rf council-planning-tool

What is my folder called?

Replace council-planning-tool with whatever you named your project folder in Step 1.

  • Delete your ElevenLabs API key to revoke access. Sign in at elevenlabs.io, open your API Keys list, click the More Actions (...) icon next to the key you created for this project, and select Delete. This is irreversible.
  • Delete your ElevenLabs account (optional). Click your profile icon in the top-right corner, select Settings, scroll down to the red Delete Account button, and confirm by entering your email. This cannot be undone and you cannot reuse the same email address.
  • Your Azure OpenAI deployment was pre-existing. No cleanup is needed unless you want to remove the deployment from your Foundry subscription separately.

Deletion is permanent

Deleting the project folder removes all Python scripts, your virtual environment, generated audio files, and JSON outputs. Make sure you have saved any outputs you want to keep before running the delete command.

Nice Work!

Nice Work!

Nice work! You just built a multi-API Python pipeline that transforms raw council meeting audio into structured decision reports and accessible audio briefings.

You've learned how to:

  • Transcribe multi-speaker audio using ElevenLabs Scribe v2 with speaker diarization and domain-specific keyterm prompting to accurately capture planning vocabulary.
  • Extract structured planning decisions from unstructured transcripts using Azure OpenAI with a defined JSON schema for applications, outcomes, conditions, and policy references.
  • Generate an accessible two-voice audio briefing using ElevenLabs Text to Dialogue with audio tags for expressive delivery.
  • Secret Mission: Built a batch processing mode that handles multiple meetings and produces a weekly planning digest with concatenated audio.

Ready to quiz yourself?