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:
parent
08c6504589
commit
2aac10fc5d
14 changed files with 552 additions and 28 deletions
33
packages/core/__tests__/meeting-transcript.test.ts
Normal file
33
packages/core/__tests__/meeting-transcript.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
54
packages/core/src/meeting-transcript.ts
Normal file
54
packages/core/src/meeting-transcript.ts
Normal 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')
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue