feat(desktop): send meeting transcript segments and preset prompt edits to the phone

The phone draws a meeting from its transcript segments before the edited
transcript, so desktop edits, auto-polish and diarization never showed there.
Every desktop transcript change now rebuilds the meeting's segments from its
[MM:SS] [speaker] lines and trims the rest; the line parser moves to
@d3ro/core/meeting-transcript and the meeting view uses it too.

Prompt edits of the four desktop presets that exist on the phone update the
server preset row (a reset restores its default; {{targetLanguage}} is sent
as English, the only target on both sides), and edits made on another desktop
come back. The free-prompt preset has no phone counterpart and stays local.
This commit is contained in:
Yun Chan 2026-09-27 16:24:25 +09:00
parent 08c6504589
commit 2aac10fc5d
14 changed files with 552 additions and 28 deletions

View file

@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest'
import { formatTranscriptLines, parseTranscriptLines } from '../src/meeting-transcript'
describe('meeting transcript lines', () => {
it('parses timestamps and speakers written by the desktop recorder and diarization', () => {
const text = '[00:05] 안녕하세요\n\n[01:12] [화자 2] 다음 안건입니다\n[12:03] [Speaker 1] 끝'
expect(parseTranscriptLines(text)).toEqual([
{ timestampMs: 5_000, timed: true, speaker: null, text: '안녕하세요' },
{ timestampMs: 72_000, timed: true, speaker: '화자 2', text: '다음 안건입니다' },
{ timestampMs: 723_000, timed: true, speaker: 'Speaker 1', text: '끝' },
])
})
it('keeps untimed lines (phone transcripts, manual edits) and carries the last time forward', () => {
expect(parseTranscriptLines('first line\n[00:30] timed\nfollow-up')).toEqual([
{ timestampMs: 0, timed: false, speaker: null, text: 'first line' },
{ timestampMs: 30_000, timed: true, speaker: null, text: 'timed' },
{ timestampMs: 30_000, timed: false, speaker: null, text: 'follow-up' },
])
})
it('drops empty and timestamp-only lines and round-trips through the formatter', () => {
const text = '[00:01] [화자 1] a\n[00:02]\nb'
const lines = parseTranscriptLines(text)
expect(lines).toHaveLength(2)
expect(formatTranscriptLines(lines)).toBe('[00:01] [화자 1] a\nb')
})
it('returns nothing for empty input', () => {
expect(parseTranscriptLines(null)).toEqual([])
expect(parseTranscriptLines(' \n ')).toEqual([])
})
})

View file

@ -42,6 +42,10 @@
"types": "./src/caption-streaming.ts",
"default": "./src/caption-streaming.ts"
},
"./meeting-transcript": {
"types": "./src/meeting-transcript.ts",
"default": "./src/meeting-transcript.ts"
},
"./entitlement": {
"types": "./src/entitlement.ts",
"default": "./src/entitlement.ts"

View file

@ -0,0 +1,54 @@
// packages/core/src/meeting-transcript.ts
// 회의 전사 텍스트 ↔ 구간(segment) 변환의 정본.
// 데스크톱은 전사를 `[MM:SS] [화자] 내용` 줄로 저장하고, Supabase `transcripts` 는 구간 행으로 저장한다
// (모바일은 구간이 있으면 구간을 우선 표시한다). 두 표현을 이 한 곳에서만 바꾼다.
export interface TranscriptLine {
/** 회의 시작 기준 ms. 시각 표기가 없는 줄은 앞 줄의 시각을 이어받는다(처음이면 0). */
timestampMs: number
/** 시각 표기가 있었는지 — 없으면 화면이 순번으로 대신 표시할 수 있다 */
timed: boolean
speaker: string | null
text: string
}
const LINE_PATTERN = /^\[(\d{1,3}):(\d{2})\]\s*(?:\[([^\]]+)\]\s*)?(.*)$/
/** 빈 줄을 버리고 각 줄을 구간으로 나눈다. 내용이 빈 줄(`[00:01]` 만 있는 줄)도 버린다. */
export function parseTranscriptLines(text: string | null | undefined): TranscriptLine[] {
if (!text) return []
const lines: TranscriptLine[] = []
let lastTimestamp = 0
for (const raw of text.split('\n')) {
const line = raw.trim()
if (!line) continue
const match = LINE_PATTERN.exec(line)
if (match) {
const content = match[4].trim()
lastTimestamp = (Number(match[1]) * 60 + Number(match[2])) * 1000
if (!content) continue
lines.push({ timestampMs: lastTimestamp, timed: true, speaker: match[3]?.trim() || null, text: content })
} else {
lines.push({ timestampMs: lastTimestamp, timed: false, speaker: null, text: line })
}
}
return lines
}
export function formatTranscriptTimestamp(ms: number): string {
const totalSeconds = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
}
/** parseTranscriptLines 의 역변환(시각 표기가 없던 줄은 그대로). */
export function formatTranscriptLines(lines: readonly TranscriptLine[]): string {
return lines
.map((l) => {
if (!l.timed) return l.text
const speaker = l.speaker ? `[${l.speaker}] ` : ''
return `[${formatTranscriptTimestamp(l.timestampMs)}] ${speaker}${l.text}`
})
.join('\n')
}