Ionosphere.tv
3
fork

Configure Feed

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

feat: Lens 1 — compact transcript to layers.pub expression + segmentation

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

+220
+55
apps/ionosphere-appview/src/__tests__/layers-pub.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { transcriptToLayersPub } from '../../../../formats/tv.ionosphere/ts/layers-pub.js'; 3 + 4 + describe('Lens 1: transcript → expression + segmentation', () => { 5 + const transcript = { 6 + $type: 'tv.ionosphere.transcript', 7 + talkUri: 'at://did:plc:test/tv.ionosphere.talk/test-talk', 8 + text: 'Hello world foo bar', 9 + startMs: 1000, 10 + // word durations: Hello=200ms, world=300ms, 100ms gap, foo=150ms, bar=250ms 11 + timings: [200, 300, -100, 150, 250], 12 + }; 13 + 14 + const did = 'did:plc:test'; 15 + const talkRkey = 'test-talk'; 16 + 17 + it('produces an expression record with correct fields', async () => { 18 + const { expression } = await transcriptToLayersPub(transcript, did, talkRkey); 19 + expect(expression.$type).toBe('pub.layers.expression.expression'); 20 + expect(expression.id).toBe('test-talk'); 21 + expect(expression.kind).toBe('transcript'); 22 + expect(expression.text).toBe('Hello world foo bar'); 23 + expect(expression.language).toBe('en'); 24 + expect(expression.sourceRef).toBe('at://did:plc:test/tv.ionosphere.transcript/test-talk-transcript'); 25 + expect(expression.metadata.tool).toBe('ionosphere-pipeline'); 26 + expect(expression.metadata.timestamp).toBeDefined(); 27 + expect(expression.createdAt).toBeDefined(); 28 + }); 29 + 30 + it('produces a segmentation record with word tokens', async () => { 31 + const { segmentation } = await transcriptToLayersPub(transcript, did, talkRkey); 32 + expect(segmentation.$type).toBe('pub.layers.segmentation.segmentation'); 33 + expect(segmentation.expression).toBe( 34 + 'at://did:plc:test/pub.layers.expression.expression/test-talk-expression' 35 + ); 36 + expect(segmentation.tokenizations).toHaveLength(1); 37 + 38 + const tok = segmentation.tokenizations[0]; 39 + expect(tok.kind).toBe('word'); 40 + expect(tok.tokens).toHaveLength(4); 41 + 42 + // Check first token 43 + expect(tok.tokens[0].tokenIndex).toBe(0); 44 + expect(tok.tokens[0].text).toBe('Hello'); 45 + expect(tok.tokens[0].textSpan.byteStart).toBe(0); 46 + expect(tok.tokens[0].textSpan.byteEnd).toBe(5); 47 + expect(tok.tokens[0].temporalSpan.start).toBe(1000); 48 + expect(tok.tokens[0].temporalSpan.ending).toBe(1200); 49 + 50 + // Check third token (after gap) 51 + expect(tok.tokens[2].text).toBe('foo'); 52 + expect(tok.tokens[2].temporalSpan.start).toBe(1600); // 1000+200+300+100gap 53 + expect(tok.tokens[2].temporalSpan.ending).toBe(1750); 54 + }); 55 + });
+10
formats/tv.ionosphere/lenses/transcript-to-expression.lens.json
··· 1 + { 2 + "source": "tv.ionosphere.transcript", 3 + "target": ["pub.layers.expression.expression", "pub.layers.segmentation.segmentation"], 4 + "version": 1, 5 + "description": "Compact transcript to layers.pub expression + segmentation", 6 + "mappings": { 7 + "text": "expression.text", 8 + "startMs + timings": "segmentation.tokenizations[0].tokens" 9 + } 10 + }
+155
formats/tv.ionosphere/ts/layers-pub.ts
··· 1 + /** 2 + * Lens transforms from tv.ionosphere records to pub.layers records. 3 + * 4 + * Lens 1: transcriptToLayersPub 5 + * tv.ionosphere.transcript → pub.layers.expression.expression 6 + * + pub.layers.segmentation.segmentation 7 + * 8 + * The timings replay algorithm matches decodeToDocument() in 9 + * transcript-encoding.ts — uses TextEncoder for correct UTF-8 byte offsets, 10 + * and indexOf with searchFrom for word position tracking. 11 + */ 12 + 13 + export interface TranscriptRecord { 14 + $type: string; 15 + talkUri: string; 16 + text: string; 17 + startMs: number; 18 + timings: number[]; 19 + } 20 + 21 + export interface ExpressionRecord { 22 + $type: 'pub.layers.expression.expression'; 23 + id: string; 24 + kind: string; 25 + text: string; 26 + language: string; 27 + sourceRef: string; 28 + metadata: { 29 + tool: string; 30 + timestamp: string; 31 + }; 32 + createdAt: string; 33 + } 34 + 35 + export interface TokenSpan { 36 + byteStart: number; 37 + byteEnd: number; 38 + } 39 + 40 + export interface TemporalSpan { 41 + start: number; 42 + ending: number; 43 + } 44 + 45 + export interface Token { 46 + tokenIndex: number; 47 + text: string; 48 + textSpan: TokenSpan; 49 + temporalSpan: TemporalSpan; 50 + } 51 + 52 + export interface Tokenization { 53 + kind: string; 54 + tokens: Token[]; 55 + } 56 + 57 + export interface SegmentationRecord { 58 + $type: 'pub.layers.segmentation.segmentation'; 59 + expression: string; 60 + tokenizations: Tokenization[]; 61 + createdAt: string; 62 + } 63 + 64 + export interface LayersPubResult { 65 + expression: ExpressionRecord; 66 + segmentation: SegmentationRecord; 67 + } 68 + 69 + /** 70 + * Lens 1: Transform a compact transcript record into layers.pub 71 + * expression + segmentation records. 72 + * 73 + * The timings replay algorithm (matching decodeToDocument): 74 + * - Split text by whitespace to get words 75 + * - Use TextEncoder for UTF-8 byte offsets 76 + * - Initialize cursor at startMs 77 + * - Iterate timings: negative = silence gap, positive = word duration 78 + * - Each word becomes a token with text span and temporal span 79 + */ 80 + export async function transcriptToLayersPub( 81 + transcript: TranscriptRecord, 82 + did: string, 83 + talkRkey: string, 84 + ): Promise<LayersPubResult> { 85 + const now = new Date().toISOString(); 86 + 87 + // Build expression record 88 + const expression: ExpressionRecord = { 89 + $type: 'pub.layers.expression.expression', 90 + id: talkRkey, 91 + kind: 'transcript', 92 + text: transcript.text, 93 + language: 'en', 94 + sourceRef: `at://${did}/tv.ionosphere.transcript/${talkRkey}-transcript`, 95 + metadata: { 96 + tool: 'ionosphere-pipeline', 97 + timestamp: now, 98 + }, 99 + createdAt: now, 100 + }; 101 + 102 + // Build segmentation record using timings replay algorithm 103 + // (matches decodeToDocument in transcript-encoding.ts) 104 + const encoder = new TextEncoder(); 105 + const words = transcript.text.split(/\s+/).filter((w) => w.length > 0); 106 + const tokens: Token[] = []; 107 + 108 + let cursor = transcript.startMs; // ms 109 + let wordIndex = 0; 110 + let searchFrom = 0; 111 + 112 + for (const value of transcript.timings) { 113 + if (value < 0) { 114 + // Silence gap — advance cursor by absolute value 115 + cursor += Math.abs(value); 116 + } else { 117 + // Word duration 118 + if (wordIndex < words.length) { 119 + const word = words[wordIndex]; 120 + const idx = transcript.text.indexOf(word, searchFrom); 121 + if (idx !== -1) { 122 + const byteStart = encoder.encode(transcript.text.slice(0, idx)).length; 123 + const byteEnd = encoder.encode( 124 + transcript.text.slice(0, idx + word.length), 125 + ).length; 126 + 127 + tokens.push({ 128 + tokenIndex: wordIndex, 129 + text: word, 130 + textSpan: { byteStart, byteEnd }, 131 + temporalSpan: { start: cursor, ending: cursor + value }, 132 + }); 133 + 134 + searchFrom = idx + word.length; 135 + } 136 + cursor += value; 137 + wordIndex++; 138 + } 139 + } 140 + } 141 + 142 + const segmentation: SegmentationRecord = { 143 + $type: 'pub.layers.segmentation.segmentation', 144 + expression: `at://${did}/pub.layers.expression.expression/${talkRkey}-expression`, 145 + tokenizations: [ 146 + { 147 + kind: 'word', 148 + tokens, 149 + }, 150 + ], 151 + createdAt: now, 152 + }; 153 + 154 + return { expression, segmentation }; 155 + }