Menu

Change language

GitHub repo

Audio Waveform Transcript

blockmedia

Descript and Otter style interactive podcast speech-to-text transcript with speaker diarization tags, interactive audio waveform scrubber, keyword search highlighting, talking time ratio analytics, clickable timecodes, and VTT/TXT export.

Installation

$npx uipkge-ng@latest add audio-waveform-transcript
Manifest:https://uipkge.dev/r/angular/audio-waveform-transcript.jsonInstalls to:src/app/components/blocks/audio-waveform-transcript/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

SpeakerTurn
interface SpeakerTurn {
  id: string
  speakerId: 'elena' | 'marcus'
  speakerName: string
  speakerRole: 'Host' | 'Guest'
  speakerHandle: string
  avatarFallback: string
  startTime: number // in seconds
  endTime: number // in seconds
  timecode: string // e.g. '00:15'
  duration: string // e.g. '04:07'
  text: string
}
SpeakerProfile
interface SpeakerProfile {
  id: 'elena' | 'marcus'
  name: string
  role: 'Host' | 'Guest'
  handle: string
  avatarFallback: string
  ratioPercent: number
  totalSpeakingTime: string
  totalSeconds: number
  turnsCount: number
  wordCount: number
  wpm: number
  accentColor: string
}
TranscriptTextSegment
interface TranscriptTextSegment {
  text: string
  match: boolean
}

Files installed (2)

  • src/app/components/blocks/audio-waveform-transcript/audio-waveform-transcript.component.ts44.4 kB
    // Descript and Otter style interactive podcast speech-to-text transcript with speaker
    // diarization tags, interactive audio waveform scrubber, keyword search highlighting,
    // talking time ratio analytics, clickable timecodes, and VTT/TXT export. Port of the
    // Vue/React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      Check,
      ChevronDown,
      Clock,
      Copy,
      Download,
      FileCode2,
      FileText,
      Link,
      LucideAngularModule,
      Mic,
      Pause,
      Play,
      Quote,
      Radio,
      RotateCcw,
      RotateCw,
      Search,
      Volume2,
      VolumeX,
      X,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiAvatarComponent, UiAvatarFallbackComponent } from '@/ui/avatar/avatar.component'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import {
      UiCardComponent,
      UiCardContentComponent,
      UiCardDescriptionComponent,
      UiCardHeaderComponent,
      UiCardTitleComponent,
    } from '@/ui/card/card.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiSeparatorComponent } from '@/ui/separator/separator.component'
    
    export interface SpeakerTurn {
      id: string
      speakerId: 'elena' | 'marcus'
      speakerName: string
      speakerRole: 'Host' | 'Guest'
      speakerHandle: string
      avatarFallback: string
      startTime: number // in seconds
      endTime: number // in seconds
      timecode: string // e.g. '00:15'
      duration: string // e.g. '04:07'
      text: string
    }
    
    export interface SpeakerProfile {
      id: 'elena' | 'marcus'
      name: string
      role: 'Host' | 'Guest'
      handle: string
      avatarFallback: string
      ratioPercent: number
      totalSpeakingTime: string
      totalSeconds: number
      turnsCount: number
      wordCount: number
      wpm: number
      accentColor: string
    }
    
    export interface TranscriptTextSegment {
      text: string
      match: boolean
    }
    
    const TOTAL_DURATION_SECONDS = 2912 // 48:32
    
    const speakers: Record<'elena' | 'marcus', SpeakerProfile> = {
      elena: {
        id: 'elena',
        name: 'Elena Rostova',
        role: 'Host',
        handle: '@erostova',
        avatarFallback: 'ER',
        ratioPercent: 45,
        totalSpeakingTime: '21:50',
        totalSeconds: 1310,
        turnsCount: 3,
        wordCount: 4120,
        wpm: 142,
        accentColor: 'bg-primary text-primary-foreground',
      },
      marcus: {
        id: 'marcus',
        name: 'Marcus Vance',
        role: 'Guest',
        handle: '@marcusvance',
        avatarFallback: 'MV',
        ratioPercent: 55,
        totalSpeakingTime: '26:42',
        totalSeconds: 1602,
        turnsCount: 2,
        wordCount: 4860,
        wpm: 156,
        accentColor: 'bg-info text-info-foreground',
      },
    }
    
    const turns: SpeakerTurn[] = [
      {
        id: 'turn-1',
        speakerId: 'elena',
        speakerName: 'Elena Rostova',
        speakerRole: 'Host',
        speakerHandle: '@erostova',
        avatarFallback: 'ER',
        startTime: 15,
        endTime: 262,
        timecode: '00:15',
        duration: '04:07',
        text: 'Welcome back to the Design Systems Podcast. Today we are diving into one of the most consequential shifts happening in frontend architecture: moving away from monolithic UI component libraries published on npm toward unbundled, copy-and-paste registries where developers own the code. I am joined by Marcus Vance, principal architect at Component Lab. Marcus, when you look at teams maintaining enterprise design systems today, why has the traditional npm package model started to show its limits?',
      },
      {
        id: 'turn-2',
        speakerId: 'marcus',
        speakerName: 'Marcus Vance',
        speakerRole: 'Guest',
        speakerHandle: '@marcusvance',
        avatarFallback: 'MV',
        startTime: 262,
        endTime: 765,
        timecode: '04:22',
        duration: '08:23',
        text: 'Thanks for having me, Elena. The fundamental challenge with versioned npm component libraries is the tight coupling between visual tokens, DOM accessibility primitives, and release cycles. When a consumer app needs a minor adjustment to an accessibility attribute or a custom animation spring, they end up blocked on a semver release or forced into fragile CSS overrides. With the registry model pioneered by shadcn, components are delivered as transparent source code. You get the benefit of world-class headless defaults—like Reka UI and Radix—while retaining complete ownership to tailor and extend the implementation directly inside your repository.',
      },
      {
        id: 'turn-3',
        speakerId: 'elena',
        speakerName: 'Elena Rostova',
        speakerRole: 'Host',
        speakerHandle: '@erostova',
        avatarFallback: 'ER',
        startTime: 765,
        endTime: 1450,
        timecode: '12:45',
        duration: '11:25',
        text: 'That distinction between primitives and blocks is so critical. In our work, we insist that primitives like Button, Card, and Dialog strictly encapsulate mechanics—focus trapping, keyboard navigation, and theme tokens—while blocks compose those primitives into layouts top-to-bottom without baking in rigid schemas. What is your take on multi-framework parity? How should teams bridge the gap when supporting both Vue and React without duplicating tokens or drift in visual behavior?',
      },
      {
        id: 'turn-4',
        speakerId: 'marcus',
        speakerName: 'Marcus Vance',
        speakerRole: 'Guest',
        speakerHandle: '@marcusvance',
        avatarFallback: 'MV',
        startTime: 1450,
        endTime: 2295,
        timecode: '24:10',
        duration: '14:05',
        text: 'Single source of truth at the CSS and token layer is non-negotiable. If you define your OKLCH color palettes, semantic typography scales, and spring easing curves in standard Tailwind tokens, both the Vue SFC and React JSX implementations consume the identical styling contracts. Headless primitives in Vue via Reka UI and in React via Radix share nearly 1:1 state machines. When the code is unbundled, automated visual regression tools can diff both frameworks side-by-side on every commit, ensuring zero drift.',
      },
      {
        id: 'turn-5',
        speakerId: 'elena',
        speakerName: 'Elena Rostova',
        speakerRole: 'Host',
        speakerHandle: '@erostova',
        avatarFallback: 'ER',
        startTime: 2295,
        endTime: 2912,
        timecode: '38:15',
        duration: '10:17',
        text: 'That clarity is empowering. For engineering teams looking to adopt this architecture, we have published the complete registry manifests, storybook-grade island demos, and cross-framework components at uipkge.dev. Marcus, thank you so much for joining us and breaking down the future of modern component engineering. Check the episode description below for the full transcript files, timecode markers, and code examples.',
      },
    ]
    
    // 72-bar simulated waveform amplitude dataset
    const waveformBars = [
      28, 42, 60, 82, 68, 45, 32, 54, 76, 92, 98, 75, 50, 35, 58, 70, 88, 96, 90, 64, 48, 62, 80, 94, 86, 55, 40, 66, 84,
      98, 100, 88, 62, 42, 54, 76, 90, 94, 80, 58, 45, 68, 85, 96, 90, 70, 52, 36, 56, 78, 90, 92, 82, 60, 48, 70, 88, 96,
      90, 72, 50, 35, 52, 70, 86, 92, 80, 58, 42, 30, 22, 16,
    ]
    
    function formatSeconds(seconds: number): string {
      const mins = Math.floor(seconds / 60)
      const secs = Math.floor(seconds % 60)
      return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-audio-waveform-transcript, [ui-audio-waveform-transcript]',
      standalone: true,
      // The block renders its own stacked root <div>: keep the host out of layout.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiAvatarComponent,
        UiAvatarFallbackComponent,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiCardContentComponent,
        UiCardDescriptionComponent,
        UiCardHeaderComponent,
        UiCardTitleComponent,
        UiInputComponent,
        UiSeparatorComponent,
      ],
      template: `
        <div data-slot="audio-waveform-transcript" [class]="rootClass">
          <!-- Top Audio Bar Card -->
          <div ui-card class="border-border bg-card overflow-hidden shadow-xs">
            <div ui-card-header class="pb-4">
              <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
                <div class="space-y-1">
                  <div class="flex flex-wrap items-center gap-2">
                    <span ui-badge wrap variant="secondary" class="gap-1 font-mono text-xs">
                      <lucide-icon [img]="Radio" class="text-primary size-3 animate-pulse" />
                      Episode 48
                    </span>
                    <span ui-badge wrap variant="outline" class="text-muted-foreground text-xs"> Audio Transcript </span>
                    <span class="text-muted-foreground font-mono text-xs tabular-nums"> MP3 · 320 kbps · Stereo </span>
                  </div>
                  <h2 ui-card-title class="text-xl font-bold tracking-tight sm:text-2xl">
                    Design Systems Podcast · Episode 48 Transcript
                  </h2>
                  <p ui-card-description class="text-muted-foreground text-xs sm:text-sm">
                    Elena Rostova with Marcus Vance on unbundled registries, token architecture, and headless Vue &amp;
                    React parity.
                  </p>
                </div>
    
                <!-- Export Transcript Menu Action -->
                <div class="relative shrink-0">
                  <button
                    ui-button
                    aria-label="Download attachment"
                    variant="outline"
                    size="sm"
                    class="gap-1.5 font-medium shadow-xs"
                    (click)="isExportMenuOpen.set(!isExportMenuOpen())"
                  >
                    <lucide-icon [img]="Download" class="text-muted-foreground size-3.5" />
                    <span>Export Transcript</span>
                    <lucide-icon [img]="ChevronDown" class="text-muted-foreground size-3" />
                  </button>
    
                  @if (isExportMenuOpen()) {
                    <div
                      class="border-border bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 absolute top-full right-0 z-50 mt-2 w-56 rounded-lg border p-1 shadow-md"
                    >
                      <div class="text-muted-foreground px-2 py-1.5 text-xs font-semibold">Download Formats</div>
                      <button
                        type="button"
                        class="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
                        (click)="downloadVtt()"
                      >
                        <lucide-icon [img]="FileCode2" class="text-primary size-3.5" />
                        <div>
                          <div class="font-medium">Export as .VTT</div>
                          <div class="text-muted-foreground text-xs">WebVTT subtitle captions</div>
                        </div>
                      </button>
                      <button
                        type="button"
                        class="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
                        (click)="downloadTxt()"
                      >
                        <lucide-icon [img]="FileText" class="text-muted-foreground size-3.5" />
                        <div>
                          <div class="font-medium">Export as .TXT</div>
                          <div class="text-muted-foreground text-xs">Plain text with timestamps</div>
                        </div>
                      </button>
                      <ui-separator class="my-1" />
                      <button
                        type="button"
                        class="hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs"
                        (click)="copyFullTranscript()"
                      >
                        <lucide-icon [img]="Copy" class="text-muted-foreground size-3.5" />
                        <span class="font-medium">Copy Full Text to Clipboard</span>
                      </button>
                    </div>
                  }
                </div>
              </div>
            </div>
    
            <div ui-card-content class="space-y-4">
              <!-- Interactive Waveform Scrubber -->
              <div class="space-y-2">
                <div class="text-muted-foreground flex items-center justify-between gap-x-2 font-mono text-xs tabular-nums">
                  <span class="text-foreground font-semibold">{{ formatTime(currentTime()) }}</span>
                  <span class="text-xs">{{ formatTime(totalDuration) }}</span>
                </div>
    
                <div
                  class="group bg-muted/40 border-border/60 hover:bg-muted/60 focus-visible:ring-ring relative h-16 w-full cursor-pointer rounded-lg border p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
                  role="slider"
                  tabindex="0"
                  aria-label="Audio scrubber"
                  [attr.aria-valuenow]="currentTime()"
                  aria-valuemin="0"
                  [attr.aria-valuemax]="totalDuration"
                  (click)="handleWaveformClick($event)"
                  (mousemove)="handleWaveformMouseMove($event)"
                  (mouseleave)="handleWaveformMouseLeave()"
                  (keydown.arrowleft)="$event.preventDefault(); skip(-5)"
                  (keydown.arrowright)="$event.preventDefault(); skip(5)"
                  (keydown.home)="$event.preventDefault(); seekTo(0)"
                  (keydown.end)="$event.preventDefault(); seekTo(totalDuration)"
                >
                  @if (hoverPillLeft !== null) {
                    <div
                      class="bg-foreground text-background pointer-events-none absolute -top-7 z-20 -translate-x-1/2 rounded px-1.5 py-0.5 font-mono text-xs tabular-nums shadow-xs"
                      [style.left]="hoverPillLeft"
                    >
                      {{ hoverTimecode() }}
                    </div>
                  }
    
                  <!-- Waveform bars -->
                  <div class="flex h-full w-full items-center justify-between gap-[2px]">
                    @for (amplitude of waveformBars; track $index) {
                      <div
                        class="flex-1 rounded-full transition-colors duration-100"
                        [style.height.%]="barHeight(amplitude)"
                        [class]="barClass($index)"
                      ></div>
                    }
                  </div>
    
                  <!-- Playhead cursor line -->
                  <div
                    class="bg-foreground pointer-events-none absolute top-0 bottom-0 w-0.5 shadow-xs"
                    [style.left.%]="playheadPercent"
                  >
                    <div class="bg-foreground absolute -top-1 -left-1 size-2.5 rounded-full"></div>
                  </div>
    
                  <!-- Speaker distribution ribbon beneath waveform -->
                  <div class="absolute right-0 -bottom-1 left-0 flex h-1 overflow-hidden rounded-b">
                    <div class="bg-primary/70 w-[8.9%]" title="Elena (00:15 - 04:22)"></div>
                    <div class="bg-info/70 w-[17.3%]" title="Marcus (04:22 - 12:45)"></div>
                    <div class="bg-primary/70 w-[23.6%]" title="Elena (12:45 - 24:10)"></div>
                    <div class="bg-info/70 w-[29.0%]" title="Marcus (24:10 - 38:15)"></div>
                    <div class="bg-primary/70 w-[21.2%]" title="Elena (38:15 - 48:32)"></div>
                  </div>
                </div>
              </div>
    
              <!-- Audio Player Controls Bar -->
              <div class="flex flex-wrap items-center justify-between gap-3 pt-1">
                <div class="flex flex-wrap items-center gap-2">
                  <button
                    ui-button
                    variant="outline"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-8"
                    title="Rewind 15 seconds"
                    (click)="skip(-15)"
                    aria-label="Action"
                  >
                    <lucide-icon [img]="RotateCcw" class="size-4" />
                  </button>
    
                  <button
                    ui-button
                    size="icon"
                    variant="default"
                    class="size-10 rounded-full shadow-sm"
                    [title]="isPlaying() ? 'Pause audio' : 'Play audio'"
                    (click)="togglePlay()"
                  >
                    @if (isPlaying()) {
                      <lucide-icon [img]="Pause" class="size-5" />
                    } @else {
                      <lucide-icon [img]="Play" class="ml-0.5 size-5 fill-current" />
                    }
                  </button>
    
                  <button
                    ui-button
                    variant="outline"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-8"
                    title="Skip forward 15 seconds"
                    (click)="skip(15)"
                    aria-label="Rotate"
                  >
                    <lucide-icon [img]="RotateCw" class="size-4" />
                  </button>
    
                  <div
                    class="text-muted-foreground ml-2 hidden items-center gap-1.5 font-mono text-xs tabular-nums sm:flex"
                  >
                    <lucide-icon [img]="Clock" class="text-muted-foreground size-3.5" />
                    <span>{{ formatTime(currentTime()) }} / {{ formatTime(totalDuration) }}</span>
                  </div>
                </div>
    
                <!-- Speed and Volume controls -->
                <div class="flex flex-wrap items-center gap-2">
                  <div class="border-border bg-muted/30 flex items-center rounded-lg border p-0.5 text-xs">
                    @for (speed of speeds; track speed) {
                      <button
                        type="button"
                        class="cursor-pointer rounded px-2 py-1 font-mono font-medium transition-colors"
                        [class]="speedClass(speed)"
                        (click)="setSpeed(speed)"
                      >
                        {{ speed }}x
                      </button>
                    }
                  </div>
    
                  <button
                    ui-button
                    variant="ghost"
                    size="icon"
                    class="text-muted-foreground hover:text-foreground size-8"
                    [title]="isMuted() ? 'Unmute' : 'Mute'"
                    (click)="toggleMute()"
                  >
                    @if (isMuted()) {
                      <lucide-icon [img]="VolumeX" class="text-destructive size-4" />
                    } @else {
                      <lucide-icon [img]="Volume2" class="size-4" />
                    }
                  </button>
                </div>
              </div>
            </div>
          </div>
    
          <!-- Speaker Talking Time Ratio Bar Card -->
          <div ui-card class="border-border bg-card shadow-xs">
            <div ui-card-header class="pb-3">
              <div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
                <div class="flex flex-wrap items-center gap-2">
                  <lucide-icon [img]="Mic" class="text-primary size-4" />
                  <h2 ui-card-title class="text-base font-semibold">Speaker Diarization &amp; Talking Time</h2>
                </div>
                <div class="text-muted-foreground text-xs">2 speakers identified · 48m 32s total recorded speech</div>
              </div>
            </div>
    
            <div ui-card-content class="space-y-4">
              <div class="space-y-1.5">
                <div class="flex justify-between font-mono text-xs tabular-nums">
                  <span class="text-primary flex items-center gap-1 font-medium">
                    <span class="bg-primary inline-block size-2 rounded-full"></span>
                    Elena Rostova (Host): 45% (21:50)
                  </span>
                  <span class="text-info flex items-center gap-1 font-medium">
                    Marcus Vance (Guest): 55% (26:42)
                    <span class="bg-info inline-block size-2 rounded-full"></span>
                  </span>
                </div>
    
                <div class="bg-muted/60 border-border/40 flex h-3 w-full gap-0.5 overflow-hidden rounded-full border p-0.5">
                  <div
                    class="bg-primary h-full rounded-l-full transition-[width] duration-300"
                    style="width: 45%"
                    title="Elena Rostova: 45% talking time"
                  ></div>
                  <div
                    class="bg-info h-full rounded-r-full transition-[width] duration-300"
                    style="width: 55%"
                    title="Marcus Vance: 55% talking time"
                  ></div>
                </div>
              </div>
    
              <!-- Speaker Metadata Cards Grid -->
              <div class="grid grid-cols-1 gap-3 pt-1 md:grid-cols-2">
                <button
                  type="button"
                  class="focus-visible:ring-ring flex w-full cursor-pointer items-center justify-between rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
                  [class]="speakerCardClass('elena')"
                  [attr.aria-pressed]="selectedSpeakerFilter() === 'elena'"
                  (click)="toggleSpeakerFilter('elena')"
                >
                  <div class="flex items-center gap-3">
                    <ui-avatar class="border-border size-9 border">
                      <ui-avatar-fallback class="bg-primary/10 text-primary text-xs font-semibold">
                        {{ speakers.elena.avatarFallback }}
                      </ui-avatar-fallback>
                    </ui-avatar>
                    <div>
                      <div class="flex items-center gap-1.5">
                        <span class="text-sm font-semibold">{{ speakers.elena.name }}</span>
                        <span ui-badge wrap variant="default" class="px-1.5 py-0 text-xs">Host</span>
                      </div>
                      <div class="text-muted-foreground text-xs">{{ speakers.elena.handle }}</div>
                    </div>
                  </div>
                  <div class="space-y-0.5 text-right font-mono text-xs tabular-nums">
                    <div class="text-foreground font-semibold">{{ speakers.elena.totalSpeakingTime }}</div>
                    <div class="text-muted-foreground text-xs">
                      {{ speakers.elena.wordCount.toLocaleString() }} words · {{ speakers.elena.wpm }} wpm
                    </div>
                  </div>
                </button>
    
                <button
                  type="button"
                  class="focus-visible:ring-ring flex w-full cursor-pointer items-center justify-between rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none"
                  [class]="speakerCardClass('marcus')"
                  [attr.aria-pressed]="selectedSpeakerFilter() === 'marcus'"
                  (click)="toggleSpeakerFilter('marcus')"
                >
                  <div class="flex items-center gap-3">
                    <ui-avatar class="border-border size-9 border">
                      <ui-avatar-fallback class="bg-info/10 text-info text-xs font-semibold">
                        {{ speakers.marcus.avatarFallback }}
                      </ui-avatar-fallback>
                    </ui-avatar>
                    <div>
                      <div class="flex items-center gap-1.5">
                        <span class="text-sm font-semibold">{{ speakers.marcus.name }}</span>
                        <span ui-badge wrap variant="secondary" class="bg-info/10 text-info px-1.5 py-0 text-xs">
                          Guest
                        </span>
                      </div>
                      <div class="text-muted-foreground text-xs">{{ speakers.marcus.handle }}</div>
                    </div>
                  </div>
                  <div class="space-y-0.5 text-right font-mono text-xs tabular-nums">
                    <div class="text-foreground font-semibold">{{ speakers.marcus.totalSpeakingTime }}</div>
                    <div class="text-muted-foreground text-xs">
                      {{ speakers.marcus.wordCount.toLocaleString() }} words · {{ speakers.marcus.wpm }} wpm
                    </div>
                  </div>
                </button>
              </div>
            </div>
          </div>
    
          <!-- Transcript Search and Filter Controls -->
          <div class="flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center">
            <div class="relative max-w-md flex-1">
              <lucide-icon
                [img]="Search"
                class="text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2"
              />
              <ui-input
                [value]="searchQuery()"
                (valueChange)="searchQuery.set($event)"
                type="text"
                placeholder="Search keywords in transcript (e.g. registry, token, Reka UI)..."
                class="bg-card border-border pr-20 pl-9 text-xs shadow-xs sm:text-sm"
              />
              <div class="absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1">
                @if (searchQuery().trim()) {
                  <span class="bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs tabular-nums">
                    {{ totalSearchMatches() }} {{ totalSearchMatches() === 1 ? 'match' : 'matches' }}
                  </span>
                  <button
                    type="button"
                    class="text-muted-foreground hover:text-foreground cursor-pointer rounded p-0.5"
                    title="Clear search"
                    (click)="searchQuery.set('')"
                  >
                    <lucide-icon [img]="X" class="size-3.5" />
                  </button>
                }
              </div>
            </div>
    
            <!-- Speaker Filter Pills -->
            <div class="flex items-center gap-1.5 overflow-x-auto pb-1 sm:pb-0">
              <button
                ui-button
                variant="outline"
                size="sm"
                [class]="speakerPillClass('all')"
                (click)="setSpeakerFilter('all')"
              >
                All Speakers (5)
              </button>
              <button
                ui-button
                variant="outline"
                size="sm"
                [class]="speakerPillClass('elena')"
                (click)="setSpeakerFilter('elena')"
              >
                Elena Rostova · Host (3)
              </button>
              <button
                ui-button
                variant="outline"
                size="sm"
                [class]="speakerPillClass('marcus')"
                (click)="setSpeakerFilter('marcus')"
              >
                Marcus Vance · Guest (2)
              </button>
            </div>
          </div>
    
          @if (searchQuery().trim()) {
            <div
              class="border-warning/30 bg-warning/10 text-warning flex items-center justify-between gap-x-2 rounded-lg border p-2.5 text-xs"
            >
              <div class="flex flex-wrap items-center gap-2">
                <lucide-icon [img]="Search" class="text-warning size-4" />
                <span>
                  Found <strong>{{ totalSearchMatches() }}</strong> occurrences of "<strong>{{ searchQuery() }}</strong
                  >" across transcript
                </span>
              </div>
              <button
                aria-label="Clear search"
                type="button"
                class="cursor-pointer text-xs font-semibold underline underline-offset-2 hover:opacity-80"
                (click)="searchQuery.set('')"
              >
                Clear filter
              </button>
            </div>
          }
    
          <!-- Interactive Speech-to-Text Transcript Feed -->
          <div class="space-y-4">
            @for (turn of displayedTurns(); track turn.id) {
              <div
                [id]="turn.id"
                class="group relative rounded-xl border p-4 transition-colors duration-200 sm:p-5"
                [class]="turnClass(turn.id)"
              >
                <!-- Top Turn Header -->
                <div class="mb-3 flex flex-wrap items-center justify-between gap-2">
                  <div class="flex items-center gap-3">
                    <ui-avatar class="border-border/80 size-8 border sm:size-9">
                      <ui-avatar-fallback [class]="turnAvatarClass(turn.speakerId)">
                        {{ turn.avatarFallback }}
                      </ui-avatar-fallback>
                    </ui-avatar>
    
                    <div class="flex flex-wrap items-center gap-2">
                      <span class="text-foreground text-sm font-semibold">{{ turn.speakerName }}</span>
                      <span
                        ui-badge
                        [variant]="turn.speakerRole === 'Host' ? 'default' : 'secondary'"
                        [class]="turnRoleClass(turn.speakerRole)"
                      >
                        {{ turn.speakerRole }}
                      </span>
    
                      @if (activeTurnId() === turn.id) {
                        <span
                          class="bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-xs font-medium"
                        >
                          <span class="bg-primary size-1.5 rounded-full"></span>
                          NOW PLAYING
                        </span>
                      }
                    </div>
                  </div>
    
                  <!-- Timecode Pill & Turn Actions -->
                  <div class="flex items-center gap-1.5 sm:gap-2">
                    <button
                      ui-button
                      variant="outline"
                      size="sm"
                      class="text-muted-foreground hover:text-foreground hover:border-primary h-7 cursor-pointer gap-1 px-2 font-mono text-xs tabular-nums"
                      title="Seek audio to this timestamp"
                      (click)="playTurn(turn.startTime)"
                    >
                      <lucide-icon [img]="Clock" class="text-primary size-3" />
                      <span>[{{ turn.timecode }}]</span>
                    </button>
    
                    <button
                      ui-button
                      variant="ghost"
                      size="icon"
                      class="text-muted-foreground hover:text-foreground size-7"
                      title="Copy timestamp link"
                      (click)="copyTimestampLink(turn)"
                    >
                      @if (copiedLinkId() === turn.id) {
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                      } @else {
                        <lucide-icon [img]="Link" class="size-3.5" />
                      }
                    </button>
    
                    <button
                      ui-button
                      variant="ghost"
                      size="icon"
                      class="text-muted-foreground hover:text-foreground size-7"
                      title="Quote this turn"
                      (click)="copyQuoteSnippet(turn)"
                    >
                      @if (copiedQuoteId() === turn.id) {
                        <lucide-icon [img]="Check" class="text-success size-3.5" />
                      } @else {
                        <lucide-icon [img]="Quote" class="size-3.5" />
                      }
                    </button>
    
                    <button
                      ui-button
                      variant="ghost"
                      size="icon"
                      class="text-muted-foreground hover:text-primary size-7"
                      title="Play from here"
                      (click)="playTurn(turn.startTime)"
                      aria-label="Play"
                    >
                      <lucide-icon [img]="Play" class="size-3.5 fill-current" />
                    </button>
                  </div>
                </div>
    
                <!-- Transcribed Paragraph Text with Search Highlighting -->
                <p class="text-foreground/90 selection:bg-primary/20 text-sm leading-relaxed font-normal sm:text-base">
                  @for (seg of highlightSegments(turn.text, searchQuery()); track $index) {
                    @if (seg.match) {
                      <mark
                        class="bg-warning/25 text-foreground bg-warning/30 dark:text-foreground rounded px-1 py-0.5 font-medium transition-colors"
                      >
                        {{ seg.text }}
                      </mark>
                    } @else {
                      {{ seg.text }}
                    }
                  }
                </p>
    
                <!-- Turn Footer Meta -->
                <div
                  class="text-muted-foreground/80 border-border/40 mt-3 flex items-center justify-between gap-x-2 border-t pt-2 font-mono text-xs"
                >
                  <span>Turn duration: {{ turn.duration }}</span>
                  <span class="text-xs">{{ turn.speakerHandle }}</span>
                </div>
              </div>
            }
    
            @if (displayedTurns().length === 0) {
              <div class="border-border bg-muted/20 space-y-2 rounded-xl border border-dashed px-4 py-12 text-center">
                <p class="text-foreground text-sm font-semibold">No matching transcript segments found</p>
                <p class="text-muted-foreground text-xs">
                  No dialogue matched query "{{ searchQuery() }}". Try adjusting your search term or clearing speaker
                  filters.
                </p>
                <button ui-button variant="outline" size="sm" class="mt-2 text-xs" (click)="resetFilters()">
                  Reset Search &amp; Filters
                </button>
              </div>
            }
          </div>
    
          @if (toastMessage(); as toast) {
            <div
              class="border-border bg-popover text-popover-foreground animate-in slide-in-from-bottom-2 fade-in-0 fixed right-6 bottom-6 z-50 flex flex-wrap items-center gap-2 rounded-lg border px-4 py-2.5 text-xs font-medium shadow-lg sm:text-sm"
            >
              <lucide-icon [img]="Check" class="text-success size-4 shrink-0" />
              <span>{{ toast }}</span>
            </div>
          }
        </div>
      `,
    })
    export class UiAudioWaveformTranscriptComponent {
      protected readonly Check = Check
      protected readonly ChevronDown = ChevronDown
      protected readonly Clock = Clock
      protected readonly Copy = Copy
      protected readonly Download = Download
      protected readonly FileCode2 = FileCode2
      protected readonly FileText = FileText
      protected readonly Link = Link
      protected readonly Mic = Mic
      protected readonly Pause = Pause
      protected readonly Play = Play
      protected readonly Quote = Quote
      protected readonly Radio = Radio
      protected readonly RotateCcw = RotateCcw
      protected readonly RotateCw = RotateCw
      protected readonly Search = Search
      protected readonly Volume2 = Volume2
      protected readonly VolumeX = VolumeX
      protected readonly X = X
    
      protected readonly speakers = speakers
      protected readonly waveformBars = waveformBars
      protected readonly totalDuration = TOTAL_DURATION_SECONDS
      protected readonly speeds: Array<1 | 1.25 | 1.5 | 2> = [1, 1.25, 1.5, 2]
    
      @Input('class') className?: string
    
      // Player and interactive states
      readonly currentTime = signal(262) // starts at 04:22
      readonly isPlaying = signal(false)
      readonly playbackSpeed = signal<1 | 1.25 | 1.5 | 2>(1)
      readonly isMuted = signal(false)
      readonly searchQuery = signal('')
      readonly selectedSpeakerFilter = signal<'all' | 'elena' | 'marcus'>('all')
      readonly onlyMatchingTurns = signal(false)
      readonly copiedLinkId = signal<string | null>(null)
      readonly copiedQuoteId = signal<string | null>(null)
      readonly toastMessage = signal<string | null>(null)
      readonly isExportMenuOpen = signal(false)
      readonly hoverTimecode = signal<string | null>(null)
      readonly hoverPositionPercent = signal<number | null>(null)
    
      private playTimer: ReturnType<typeof setInterval> | undefined
      private toastTimer: ReturnType<typeof setTimeout> | undefined
    
      constructor() {
        inject(DestroyRef).onDestroy(() => {
          clearInterval(this.playTimer)
          clearTimeout(this.toastTimer)
        })
      }
    
      // Active turn based on current playback time
      readonly activeTurnId = computed(() => {
        const current = turns.find((t) => this.currentTime() >= t.startTime && this.currentTime() < t.endTime)
        return current ? current.id : turns[0].id
      })
    
      // Total search matches count across all turns
      readonly totalSearchMatches = computed(() => {
        const query = this.searchQuery().trim().toLowerCase()
        if (!query) return 0
        let count = 0
        turns.forEach((turn) => {
          const text = turn.text.toLowerCase()
          let pos = 0
          while ((pos = text.indexOf(query, pos)) !== -1) {
            count++
            pos += query.length
          }
        })
        return count
      })
    
      // Filtered turns list
      readonly displayedTurns = computed(() => {
        return turns.filter((turn) => {
          if (this.selectedSpeakerFilter() !== 'all' && turn.speakerId !== this.selectedSpeakerFilter()) {
            return false
          }
          if (this.onlyMatchingTurns() && this.searchQuery().trim()) {
            return turn.text.toLowerCase().includes(this.searchQuery().trim().toLowerCase())
          }
          return true
        })
      })
    
      get rootClass(): string {
        return cn('text-foreground w-full space-y-6', this.className)
      }
    
      get playheadPercent(): number {
        return (this.currentTime() / TOTAL_DURATION_SECONDS) * 100
      }
    
      get hoverPillLeft(): string | null {
        const pos = this.hoverPositionPercent()
        return pos === null ? null : `${pos}%`
      }
    
      formatTime(seconds: number): string {
        return formatSeconds(seconds)
      }
    
      barHeight(amplitude: number): number {
        return Math.max(12, amplitude)
      }
    
      barClass(index: number): string {
        const played = (index / waveformBars.length) * TOTAL_DURATION_SECONDS <= this.currentTime()
        return cn(
          'flex-1 rounded-full transition-colors duration-100',
          played ? 'bg-primary' : 'bg-muted-foreground/30 group-hover:bg-muted-foreground/45',
        )
      }
    
      speedClass(speed: 1 | 1.25 | 1.5 | 2): string {
        return cn(
          'cursor-pointer rounded px-2 py-1 font-mono font-medium transition-colors',
          this.playbackSpeed() === speed
            ? 'bg-background text-foreground font-semibold shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      speakerCardClass(id: 'elena' | 'marcus'): string {
        const selected = this.selectedSpeakerFilter() === id
        const base =
          'focus-visible:ring-ring flex w-full cursor-pointer items-center justify-between rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none'
        if (id === 'elena') {
          return cn(
            base,
            selected ? 'border-primary bg-primary/5 ring-primary/30 ring-1' : 'border-border bg-muted/20 hover:bg-muted/40',
          )
        }
        return cn(
          base,
          selected ? 'border-info bg-info/5 ring-info/30 ring-1' : 'border-border bg-muted/20 hover:bg-muted/40',
        )
      }
    
      speakerPillClass(id: 'all' | 'elena' | 'marcus'): string {
        const selected = this.selectedSpeakerFilter() === id
        if (id === 'marcus') {
          return cn(
            'h-8 cursor-pointer text-xs',
            selected ? 'border-info bg-info text-info-foreground hover:bg-info/90' : '',
          )
        }
        return cn(
          'h-8 cursor-pointer text-xs',
          selected ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90' : '',
        )
      }
    
      turnClass(turnId: string): string {
        return cn(
          'group relative rounded-xl border p-4 transition-colors duration-200 sm:p-5',
          this.activeTurnId() === turnId
            ? 'border-primary bg-primary/[0.03] dark:bg-primary/[0.06] ring-primary/30 shadow-sm ring-1'
            : 'border-border bg-card hover:border-border/80 shadow-xs',
        )
      }
    
      turnAvatarClass(speakerId: 'elena' | 'marcus'): string {
        return cn('text-xs font-semibold', speakerId === 'elena' ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info')
      }
    
      turnRoleClass(role: 'Host' | 'Guest'): string {
        return cn('px-2 py-0 text-xs whitespace-normal', role === 'Guest' ? 'bg-info/10 text-info border-transparent' : '')
      }
    
      showToast(msg: string): void {
        this.toastMessage.set(msg)
        if (this.toastTimer) clearTimeout(this.toastTimer)
        this.toastTimer = setTimeout(() => {
          this.toastMessage.set(null)
        }, 2500)
      }
    
      togglePlay(): void {
        this.isPlaying.update((v) => !v)
        this.restartTimer()
      }
    
      seekTo(seconds: number): void {
        this.currentTime.set(Math.max(0, Math.min(TOTAL_DURATION_SECONDS, seconds)))
      }
    
      skip(deltaSeconds: number): void {
        this.seekTo(this.currentTime() + deltaSeconds)
      }
    
      setSpeed(speed: 1 | 1.25 | 1.5 | 2): void {
        this.playbackSpeed.set(speed)
        this.restartTimer()
      }
    
      toggleMute(): void {
        this.isMuted.update((v) => !v)
      }
    
      setSpeakerFilter(filter: 'all' | 'elena' | 'marcus'): void {
        this.selectedSpeakerFilter.set(filter)
      }
    
      toggleSpeakerFilter(id: 'elena' | 'marcus'): void {
        this.selectedSpeakerFilter.update((prev) => (prev === id ? 'all' : id))
      }
    
      highlightSegments(text: string, query: string): TranscriptTextSegment[] {
        const trimmed = query.trim()
        if (!trimmed) return [{ text, match: false }]
        const escaped = trimmed.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
        const regex = new RegExp(`(${escaped})`, 'gi')
        const parts = text.split(regex)
        return parts.map((part) => ({
          text: part,
          match: part.toLowerCase() === trimmed.toLowerCase(),
        }))
      }
    
      async copyTimestampLink(turn: SpeakerTurn): Promise<void> {
        const link = `https://uipkge.dev/podcast/ep48#t=${turn.timecode}`
        try {
          await navigator.clipboard.writeText(link)
          this.copiedLinkId.set(turn.id)
          this.showToast(`Copied timecode link for ${turn.timecode}`)
          setTimeout(() => {
            if (this.copiedLinkId() === turn.id) this.copiedLinkId.set(null)
          }, 2000)
        } catch {
          this.showToast(`Timecode: ${link}`)
        }
      }
    
      async copyQuoteSnippet(turn: SpeakerTurn): Promise<void> {
        const quote = `"[${turn.timecode}] ${turn.speakerName} (${turn.speakerRole}): ${turn.text}"`
        try {
          await navigator.clipboard.writeText(quote)
          this.copiedQuoteId.set(turn.id)
          this.showToast(`Quote from ${turn.speakerName} copied to clipboard`)
          setTimeout(() => {
            if (this.copiedQuoteId() === turn.id) this.copiedQuoteId.set(null)
          }, 2000)
        } catch {
          this.showToast('Quote snippet copied')
        }
      }
    
      downloadVtt(): void {
        let vttContent = `WEBVTT - Design Systems Podcast · Episode 48 Transcript\n\n`
        turns.forEach((turn, idx) => {
          const startHour = '00:' + turn.timecode + '.000'
          const endMins = Math.floor(turn.endTime / 60)
          const endSecs = Math.floor(turn.endTime % 60)
          const endTimecode = `00:${String(endMins).padStart(2, '0')}:${String(endSecs).padStart(2, '0')}.000`
    
          vttContent += `${idx + 1}\n`
          vttContent += `${startHour} --> ${endTimecode}\n`
          vttContent += `<v ${turn.speakerName}>${turn.text}\n\n`
        })
    
        const blob = new Blob([vttContent], { type: 'text/vtt;charset=utf-8;' })
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = 'design-systems-podcast-ep48.vtt'
        a.click()
        URL.revokeObjectURL(url)
        this.showToast('Downloaded WebVTT transcript file')
        this.isExportMenuOpen.set(false)
      }
    
      downloadTxt(): void {
        let txtContent = `Design Systems Podcast · Episode 48 Transcript\n`
        txtContent += `Total Duration: 48:32 | Elena Rostova (Host) · Marcus Vance (Guest)\n\n`
        txtContent += `============================================================\n\n`
    
        turns.forEach((turn) => {
          txtContent += `[${turn.timecode}] ${turn.speakerName} (${turn.speakerRole}):\n`
          txtContent += `${turn.text}\n\n`
        })
    
        const blob = new Blob([txtContent], { type: 'text/plain;charset=utf-8;' })
        const url = URL.createObjectURL(blob)
        const a = document.createElement('a')
        a.href = url
        a.download = 'design-systems-podcast-ep48.txt'
        a.click()
        URL.revokeObjectURL(url)
        this.showToast('Downloaded Plain Text transcript file')
        this.isExportMenuOpen.set(false)
      }
    
      async copyFullTranscript(): Promise<void> {
        let fullTxt = `Design Systems Podcast · Episode 48 Transcript (48:32)\n\n`
        turns.forEach((turn) => {
          fullTxt += `[${turn.timecode}] ${turn.speakerName} (${turn.speakerRole}):\n${turn.text}\n\n`
        })
        try {
          await navigator.clipboard.writeText(fullTxt)
          this.showToast('Full transcript copied to clipboard')
        } catch {
          this.showToast('Transcript copied')
        }
        this.isExportMenuOpen.set(false)
      }
    
      playTurn(startTime: number): void {
        this.seekTo(startTime)
        if (!this.isPlaying()) {
          this.isPlaying.set(true)
          this.restartTimer()
        }
      }
    
      resetFilters(): void {
        this.searchQuery.set('')
        this.selectedSpeakerFilter.set('all')
      }
    
      handleWaveformClick(event: MouseEvent): void {
        const target = event.currentTarget as HTMLElement | null
        if (!target) return
        const rect = target.getBoundingClientRect()
        const clickX = event.clientX - rect.left
        const ratio = Math.max(0, Math.min(1, clickX / rect.width))
        this.seekTo(ratio * TOTAL_DURATION_SECONDS)
      }
    
      handleWaveformMouseMove(event: MouseEvent): void {
        const target = event.currentTarget as HTMLElement | null
        if (!target) return
        const rect = target.getBoundingClientRect()
        const mouseX = event.clientX - rect.left
        const ratio = Math.max(0, Math.min(1, mouseX / rect.width))
        this.hoverPositionPercent.set(ratio * 100)
        this.hoverTimecode.set(formatSeconds(ratio * TOTAL_DURATION_SECONDS))
      }
    
      handleWaveformMouseLeave(): void {
        this.hoverPositionPercent.set(null)
        this.hoverTimecode.set(null)
      }
    
      private restartTimer(): void {
        clearInterval(this.playTimer)
        this.playTimer = undefined
        if (this.isPlaying()) {
          const intervalTime = 1000 / this.playbackSpeed()
          this.playTimer = setInterval(() => {
            if (this.currentTime() >= TOTAL_DURATION_SECONDS) {
              this.currentTime.set(TOTAL_DURATION_SECONDS)
              this.isPlaying.set(false)
              clearInterval(this.playTimer)
              this.playTimer = undefined
            } else {
              this.currentTime.update((v) => v + 1)
            }
          }, intervalTime)
        }
      }
    }
    
  • src/app/components/blocks/audio-waveform-transcript/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/audio-waveform-transcript.json