Ionosphere.tv
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: full-day stream transcription pipeline

Chunks full-day HLS streams into 20-min segments, transcribes each
via Whisper with word-level timestamps, offsets timestamps to absolute
stream position, and stitches into one continuous transcript per stream.
Caches at chunk level so interrupted runs resume cleanly.

Also adds publish-annotations.ts for targeted annotation publishing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+248
+43
apps/ionosphere-appview/src/publish-annotations.ts
··· 1 + /** 2 + * Publish only annotations to the PDS. Use when other records are 3 + * already published but annotations hit a rate limit. 4 + */ 5 + import { PdsClient } from "./pds-client.js"; 6 + import { openDb } from "./db.js"; 7 + 8 + const PDS_URL = process.env.PDS_URL ?? "http://localhost:2690"; 9 + const BOT_HANDLE = process.env.BOT_HANDLE ?? "ionosphere.test"; 10 + const BOT_PASSWORD = process.env.BOT_PASSWORD ?? "ionosphere-dev-password"; 11 + 12 + async function main() { 13 + const pds = new PdsClient(PDS_URL); 14 + await pds.login(BOT_HANDLE, BOT_PASSWORD); 15 + const did = pds.getDid(); 16 + const db = openDb(); 17 + 18 + const annotations = db.prepare("SELECT * FROM annotations").all() as any[]; 19 + console.log(`Publishing ${annotations.length} annotations...`); 20 + let count = 0; 21 + for (const ann of annotations) { 22 + const talkUri = ann.talk_uri 23 + ? ann.talk_uri.replace(/^at:\/\/[^/]+/, `at://${did}`) 24 + : null; 25 + const transcriptUri = ann.transcript_uri.replace(/^at:\/\/[^/]+/, `at://${did}`); 26 + const conceptUri = ann.concept_uri.replace(/^at:\/\/[^/]+/, `at://${did}`); 27 + await pds.putRecord("tv.ionosphere.annotation", ann.rkey, { 28 + $type: "tv.ionosphere.annotation", 29 + transcriptUri, 30 + ...(talkUri && { talkUri }), 31 + conceptUri, 32 + byteStart: ann.byte_start, 33 + byteEnd: ann.byte_end, 34 + ...(ann.text && { text: ann.text }), 35 + }); 36 + count++; 37 + if (count % 100 === 0) console.log(` ${count}/${annotations.length}`); 38 + } 39 + console.log(`Done: ${count} annotations published`); 40 + db.close(); 41 + } 42 + 43 + main().catch(console.error);
+205
apps/ionosphere-appview/src/transcribe-fullday.ts
··· 1 + /** 2 + * Transcribe full-day conference streams. 3 + * 4 + * Downloads the full HLS stream, extracts audio in 20-minute chunks, 5 + * transcribes each chunk via Whisper, and stitches word-level timestamps 6 + * back into one continuous transcript per stream. 7 + * 8 + * Usage: npx tsx src/transcribe-fullday.ts 9 + * 10 + * Environment: OPENAI_API_KEY 11 + */ 12 + import "./env.js"; 13 + import { execSync } from "node:child_process"; 14 + import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from "node:fs"; 15 + import path from "node:path"; 16 + import OpenAI from "openai"; 17 + import type { WordTimestamp } from "@ionosphere/format"; 18 + 19 + const client = new OpenAI(); 20 + 21 + const VOD_ENDPOINT = "https://vod-beta.stream.place/xrpc/place.stream.playback.getVideoPlaylist"; 22 + const DATA_DIR = path.resolve(import.meta.dirname, "../../data"); 23 + const FULLDAY_DIR = path.join(DATA_DIR, "fullday"); 24 + const CHUNK_SECONDS = 20 * 60; // 20-minute chunks for Whisper's 25MB limit 25 + 26 + // Full-day stream records 27 + const FULLDAY_STREAMS: Array<{ 28 + name: string; 29 + room: string; 30 + day: number; // 1 or 2 31 + uri: string; 32 + }> = [ 33 + { name: "Great Hall - Day 1", room: "Great Hall South", day: 1, uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadw52j22" }, 34 + { name: "Great Hall - Day 2", room: "Great Hall South", day: 2, uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miighlz53o22" }, 35 + { name: "Room 2301 - Day 1", room: "Room 2301", day: 1, uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadx2dj22" }, 36 + { name: "Room 2301 - Day 2", room: "Room 2301", day: 2, uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadxeqn22" }, 37 + { name: "Performance Theater - Day 1", room: "Performance Theatre", day: 1, uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadwgvz22" }, 38 + { name: "Performance Theater - Day 2", room: "Performance Theatre", day: 2, uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadwqgy22" }, 39 + { name: "ATScience - Full Day", room: "ATScience", day: 1, uri: "at://did:plc:rbvrr34edl5ddpuwcubjiost/place.stream.video/3miieadvruo22" }, 40 + ]; 41 + 42 + function buildPlaylistUrl(videoUri: string): string { 43 + return `${VOD_ENDPOINT}?uri=${encodeURIComponent(videoUri)}`; 44 + } 45 + 46 + /** 47 + * Get the duration of an HLS stream in seconds using ffprobe. 48 + */ 49 + function getStreamDuration(playlistUrl: string): number { 50 + try { 51 + const out = execSync( 52 + `ffprobe -v quiet -show_entries format=duration -of csv=p=0 "${playlistUrl}"`, 53 + { timeout: 60_000 } 54 + ).toString().trim(); 55 + return parseFloat(out) || 0; 56 + } catch { 57 + return 0; 58 + } 59 + } 60 + 61 + /** 62 + * Extract a chunk of audio from an HLS stream. 63 + */ 64 + function extractChunk( 65 + playlistUrl: string, 66 + startSec: number, 67 + durationSec: number, 68 + outputPath: string 69 + ): void { 70 + if (existsSync(outputPath)) return; 71 + console.log(` Extracting chunk: ${startSec}s - ${startSec + durationSec}s`); 72 + execSync( 73 + `ffmpeg -ss ${startSec} -t ${durationSec} -i "${playlistUrl}" -vn -acodec libmp3lame -ar 16000 -ac 1 -b:a 32k "${outputPath}" -y`, 74 + { stdio: "pipe", timeout: 600_000 } 75 + ); 76 + } 77 + 78 + /** 79 + * Transcribe a single audio chunk via Whisper. 80 + */ 81 + async function transcribeChunk(audioPath: string): Promise<{ text: string; words: WordTimestamp[] }> { 82 + const { createReadStream } = await import("node:fs"); 83 + const response = await client.audio.transcriptions.create({ 84 + model: "whisper-1", 85 + file: createReadStream(audioPath), 86 + response_format: "verbose_json", 87 + timestamp_granularities: ["word"], 88 + }); 89 + 90 + const words: WordTimestamp[] = (response.words ?? []).map((w) => ({ 91 + word: w.word, 92 + start: w.start, 93 + end: w.end, 94 + confidence: 1.0, 95 + })); 96 + 97 + return { text: response.text, words }; 98 + } 99 + 100 + /** 101 + * Process one full-day stream: chunk, transcribe, stitch. 102 + */ 103 + async function processStream(stream: typeof FULLDAY_STREAMS[0]): Promise<void> { 104 + const streamDir = path.join(FULLDAY_DIR, stream.name.replace(/[^a-zA-Z0-9-]/g, "_")); 105 + mkdirSync(streamDir, { recursive: true }); 106 + 107 + const resultPath = path.join(streamDir, "transcript.json"); 108 + if (existsSync(resultPath)) { 109 + console.log(` Already transcribed: ${stream.name}`); 110 + return; 111 + } 112 + 113 + const playlistUrl = buildPlaylistUrl(stream.uri); 114 + console.log(`\nProcessing: ${stream.name}`); 115 + console.log(` Playlist: ${playlistUrl}`); 116 + 117 + // Get stream duration 118 + const duration = getStreamDuration(playlistUrl); 119 + if (duration <= 0) { 120 + console.log(` ERROR: Could not determine stream duration`); 121 + return; 122 + } 123 + console.log(` Duration: ${(duration / 3600).toFixed(1)} hours (${Math.round(duration)}s)`); 124 + 125 + // Extract and transcribe chunks 126 + const numChunks = Math.ceil(duration / CHUNK_SECONDS); 127 + console.log(` Chunks: ${numChunks} x ${CHUNK_SECONDS / 60}min`); 128 + 129 + const allWords: WordTimestamp[] = []; 130 + let fullText = ""; 131 + 132 + for (let i = 0; i < numChunks; i++) { 133 + const startSec = i * CHUNK_SECONDS; 134 + const chunkDuration = Math.min(CHUNK_SECONDS, duration - startSec); 135 + const chunkPath = path.join(streamDir, `chunk-${String(i).padStart(3, "0")}.mp3`); 136 + const chunkTranscriptPath = path.join(streamDir, `chunk-${String(i).padStart(3, "0")}.json`); 137 + 138 + // Check if chunk transcript already exists 139 + if (existsSync(chunkTranscriptPath)) { 140 + console.log(` Chunk ${i + 1}/${numChunks}: cached`); 141 + const cached = JSON.parse(readFileSync(chunkTranscriptPath, "utf-8")); 142 + allWords.push(...cached.words); 143 + fullText += (fullText ? " " : "") + cached.text; 144 + continue; 145 + } 146 + 147 + // Extract audio chunk 148 + extractChunk(playlistUrl, startSec, chunkDuration, chunkPath); 149 + 150 + // Transcribe 151 + console.log(` Chunk ${i + 1}/${numChunks}: transcribing...`); 152 + const result = await transcribeChunk(chunkPath); 153 + 154 + // Offset timestamps to absolute position in the stream 155 + const offsetWords = result.words.map((w) => ({ 156 + ...w, 157 + start: w.start + startSec, 158 + end: w.end + startSec, 159 + })); 160 + 161 + // Cache chunk transcript 162 + writeFileSync(chunkTranscriptPath, JSON.stringify({ text: result.text, words: offsetWords })); 163 + 164 + allWords.push(...offsetWords); 165 + fullText += (fullText ? " " : "") + result.text; 166 + 167 + console.log(` Chunk ${i + 1}/${numChunks}: ${result.words.length} words`); 168 + } 169 + 170 + // Save stitched transcript 171 + const stitched = { 172 + stream: stream.name, 173 + room: stream.room, 174 + day: stream.day, 175 + uri: stream.uri, 176 + durationSeconds: duration, 177 + text: fullText, 178 + words: allWords, 179 + totalWords: allWords.length, 180 + }; 181 + 182 + writeFileSync(resultPath, JSON.stringify(stitched, null, 2)); 183 + console.log(` DONE: ${stream.name} — ${allWords.length} words, saved to ${resultPath}`); 184 + } 185 + 186 + // --- Main --- 187 + 188 + async function main() { 189 + mkdirSync(FULLDAY_DIR, { recursive: true }); 190 + 191 + const target = process.argv[2]; // optional: filter by stream name 192 + const streams = target 193 + ? FULLDAY_STREAMS.filter((s) => s.name.toLowerCase().includes(target.toLowerCase())) 194 + : FULLDAY_STREAMS; 195 + 196 + console.log(`Processing ${streams.length} full-day stream(s)`); 197 + 198 + for (const stream of streams) { 199 + await processStream(stream); 200 + } 201 + 202 + console.log("\nAll streams processed."); 203 + } 204 + 205 + main().catch(console.error);