Audio Waveform Transcript
blockmediaDescript 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
$pnpm dlx uipkge-ng@latest add audio-waveform-transcript$npx uipkge-ng@latest add audio-waveform-transcript$yarn dlx uipkge-ng@latest add audio-waveform-transcript$bunx uipkge-ng@latest add audio-waveform-transcriptManifest:
https://uipkge.dev/r/angular/audio-waveform-transcript.jsonInstalls to:src/app/components/blocks/audio-waveform-transcript/Variants
Loading interactive previews…
Loading interactive previews…
Schema
Type aliases exported from this item's source. Use these to shape the data you pass in.
SpeakerTurninterface 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
}SpeakerProfileinterface 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
}TranscriptTextSegmentinterface 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 & 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 & 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 & 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
export { UiAudioWaveformTranscriptComponent, type SpeakerTurn, type SpeakerProfile, type TranscriptTextSegment, } from './audio-waveform-transcript.component'
Raw manifest:https://uipkge.dev/r/angular/audio-waveform-transcript.json