Ephes Blog

Miscellaneous things. Mostly Weeknotes and links I stumbled upon.


🎙️ Introducing podcast-transcript: Audio Transcription Made Simple

, Jochen

Hey folks! I recently built a little command-line tool called podcast-transcript that turns audio into text. While it started as a podcast transcription project during the PyDDF autumn sprint, it works great for any speech audio. The coolest part? It can transcribe a 2-hour podcast in about 90 seconds!

Quick Start 🚀

pip install podcast-transcript  # or use pipx or uvx
transcribe https://d2mmy4gxasde9x.cloudfront.net/cast_audio/pp_53.mp3

Why Groq?

After trying different approaches, I landed on using the Groq API for transcription. Here's why:

  • It's blazing fast
  • Getting an API key is free and API usage is free (with reasonable limits: 8 hours of audio per day, 2 hours per hour)
  • The Whisper large-v3 model handles multiple languages well (especially noticeable for German content)

Technical Bits

The tool handles some interesting challenges under the hood:

  • Automatically resamples audio to 16kHz mono before upload (if you don't do it before, Groq will after upload)
  • Splits files larger than 25MB into chunks and stitches the transcripts back together
  • Uses httpx for direct API calls to get detailed JSON responses inspired by Simon Willison’s approach
  • Outputs in multiple formats: DOTe JSON, Podlove JSON, WebVTT, and plain text

Future Plans

I'm planning to add support for local transcription using the OpenAI Whisper model. While Whisper v2 works well enough for English content, v3 shows notable improvements for other languages (especially German). I initially skipped local processing because of the PyTorch dependency, but it's on the roadmap! I also plan to add multitrack support for handling audio files with separate speaker tracks.

The code is open source and contributions are welcome. Let me know if you try it out!


Weeknotes 2024-11-18

, Jochen

I learned all my HTML<br>
From a code camp held yearly in hell<br>
We started on `<s>`<br>
Then striked through the rest<br>
Now I have a career in DevRel --Heydon Pickering


Published a new podcast episode discussing Python 3.13. I’ve been using Jupyter notebooks to generate transcripts for the episodes and have started consolidating that workflow into a dedicated Python command-line application.

Articles

Books

Software

Out of Context Images


Weeknotes 2024-11-11

, Jochen
AAAAAAAAAAAAAAAAAAAAAAAHHHHHHHH --Endless Screaming

This is fine 🔥.

In other news, I attended the PyDDF autumn sprint! After putting off the transcripts feature for django-cast for quite some time, I finally started working on it. You can already see some transcript functionality in the web player on my podcast website. There’s still plenty to do, but at least it’s underway now.

Articles

Videos

Software

Fediverse

Out of Context Images


Weeknotes 2024-11-04

, Jochen
By age 35, your 500-foot burial pyramid should already be 80% complete. --batkaren

I’ve implemented some performance improvements on my podcast page and published an article about them: TIL: Podlove Web Player Performance Improvements. Additionally, I released a new version of django-cast that incorporates the new podlove-player web component and offers official support for Wagtail 6.3.

Articles

Software

Books

Fediverse

Weeknotes

Videos

Out of Context Images


Podlove Web Player Performance Improvements

, Jochen

I recently attended the SUBSCRIBE 11 podcasting conference, where I had the opportunity to chat with the author of Pods-Blitz about the Podlove Web Player. After we both listened to a talk about it, he asked me how I liked the Web Player. I mentioned that I really enjoyed it but suspected it was slowing down my website. He immediately agreed, noting that he had observed the same behavior.

Here's a screenshot illustrating the issue:

python-podcast-performance_original_scores

The poor performance seems to stem from the player loading a substantial amount of JavaScript and CSS assets.

podlove-player_slow_assets

Today, I decided to ask on Discord what might be causing this problem. Below is my original JavaScript code used to initialize the five audio players on the overview page of my podcast:

<script defer src="/static/cast/js/web-player/embed.5.28903a742256.js"></script>
<script>
  function initializePodlovePlayers() {
    document.querySelectorAll('section.block-audio div[id^="audio_"]').forEach(div => {
      const audioId = div.id;
      const url = div.getAttribute('data-url');
      podlovePlayer(`#${audioId}`, url, "/api/audios/player_config/");
    });
  }

  document.body.addEventListener('htmx:afterSwap', (event) => {
    if (event.detail.target.id === 'paging-area') {
      initializePodlovePlayers();
    }
  });

  // Call the function on initial load
  document.addEventListener('DOMContentLoaded', (event) => {
    initializePodlovePlayers();
  });
</script>

Alexander Heimbuch suggested that I use the load event instead of DOMContentLoaded to avoid blocking the main thread. He also advised using the IntersectionObserver API to defer the initialization until the player becomes visible in the viewport. I'm really grateful for Alex's help—it made a significant difference in optimizing my website. Based on his suggestions, I updated my code:

<script defer src="{% static 'cast/js/web-player/embed.5.js' %}"></script>
<script>
  function initializePodlovePlayerWhenVisible(div) {
    const observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          const audioId = div.id;
          const url = div.getAttribute('data-url');
          podlovePlayer(`#${audioId}`, url, "/api/audios/player_config/");
          observer.unobserve(div); // Stop observing since we don't need to initialize again
        }
      });
    });
    observer.observe(div);
  }

  function initializePodlovePlayers() {
    document.querySelectorAll('.podlove-player-container').forEach(div => {
      initializePodlovePlayerWhenVisible(div);
    });
  }

  document.body.addEventListener('htmx:afterSettle', (event) => {
    if (event.detail.target.id === 'paging-area') {
      setTimeout(() => {
        initializePodlovePlayers();
      }, 1500); // Adjust the delay as needed -> wait to finish scrolling up
    }
   });

  // Attach to the load event to ensure the page has fully loaded
  window.addEventListener('load', function() {
    initializePodlovePlayers();
  });
</script>

These changes made a significant difference—the performance score for mobile increased from 60 to 83. Realizing that it wouldn't be easy to reduce the connections to cdn.podlove.org—since each player is isolated in its own iframe and the URL is hardcoded in the pre-built player bundle—I added an additional optimization by including a preconnect link in the header of my website:

<link rel="preconnect" href="https://cdn.podlove.org" />

This also helped a lot to improve performance. Here are the final results:

python-podcast_optimized_scores

After grappling with htmx and pagination issues, I decided to encapsulate all the JavaScript into a web component. Now, the player script is dynamically imported when the web component becomes visible.

{% load static %}
{% if page.pk %}
  <podlove-player
    id="audio_{{ value.pk }}"
    data-variant="xl"
    data-url="{% url 'cast:api:audio_podlove_detail' pk=value.pk post_id=page.pk %}"
    data-embed="{% static 'cast/js/web-player/embed.5.js' %}"
    data-config="{% url 'cast:api:player_config' %}"
  >
  </podlove-player>
{% endif %}
// podlove-player.ts
class PodlovePlayerElement extends HTMLElement {
  constructor() {
    super();
    this.observer = null;
    this.shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this.renderPlaceholder();

    if (document.readyState === 'complete') {
      // The page is already fully loaded
      this.observeElement();
    } else {
      // Wait for the 'load' event before initializing
      window.addEventListener('load', () => {
        this.observeElement();
      }, { once: true });
    }
  }

  disconnectedCallback() {
    if (this.observer) {
      this.observer.disconnect();
    }
  }

  renderPlaceholder() {
    // Reserve space to prevent layout shifts
    const container = document.createElement('div');
    container.classList.add('podlove-player-container');

    // Apply styles
    const style = document.createElement('style');
    style.textContent = `
      .podlove-player-container {
        width: 100%;
        max-width: 936px;
        height: 300px;
        margin: 0 auto;
      }
      @media (max-width: 768px) {
        .podlove-player-container {
          max-width: 366px;
          height: 500px;
        }
      }
      .podlove-player-container iframe {
        width: 100%;
        height: 100%;
        border: none;
      }
    `;

    this.shadow.appendChild(style);
    this.shadow.appendChild(container);
  }

  observeElement() {
    this.observer = new IntersectionObserver((entries, observer) => {
      entries.forEach(entry => {
        if (entry.isIntersecting) {
          this.initializePlayer();
          observer.unobserve(this);
        }
      });
    });
    this.observer.observe(this);
  }

  initializePlayer() {
    const container = this.shadow.querySelector('.podlove-player-container');
    const audioId = this.getAttribute('id') || `podlove-player-${Date.now()}`;
    const url = this.getAttribute('data-url');
    const configUrl = this.getAttribute('data-config') || '/api/audios/player_config/';
    const podloveTemplate = this.getAttribute('data-template');
    let embedUrl = this.getAttribute('data-embed') || 'https://cdn.podlove.org/web-player/5.x/embed.js';

    // If host ist localhost use local embed url
    const { protocol, hostname, port } = window.location;


    console.log("data template: ", podloveTemplate);
    const playerDiv = document.createElement('div');
    playerDiv.id = audioId;

    // set the template attribute if it is set (needed for pp theme)
    if (podloveTemplate !== null) {
        playerDiv.setAttribute('data-template', podloveTemplate);
    }
    container.appendChild(playerDiv);

    if (typeof podlovePlayer === 'function') {
      podlovePlayer(playerDiv, url, configUrl);
    } else {
      // If in dev mode on localhost and embedUrl starts with a slash, use the local embedUrl
      // otherwise the vite url 5173 will be used -> which will not work
      if (hostname === 'localhost' && embedUrl.startsWith("/")) {
        embedUrl = `http://localhost:${port}${embedUrl}`;
      }
      // Dynamically load the Podlove player script
      import(embedUrl).then(() => {
        podlovePlayer(playerDiv, url, configUrl);
      });
    }
  }
}

console.log("Registering podlove-player!");
customElements.define('podlove-player', PodlovePlayerElement);