feat(site): loop a faithful copy of the app's recording capsule in the hero
Replace the press-to-play demo and the "see how it works" button with an example that keeps playing on the right: hold Right Alt, the capsule appears with live wave bars, elapsed time and the partial transcript, releasing shows the processing bar, and the cleaned sentence is pasted at the cursor of a notes window. It cycles through two samples per language. - Same numbers as apps/desktop recording-tip: 9 bars with cos weights, 100 ms updates, 0.5 smoothing, +/-35% jitter, 2-28 px, per-bar colours, 120 px progress bar min(95, (1 - 1/(1 + 1.5t)) * 100)%. - WCAG 2.2.2: a pause button; stops when off screen or the tab is hidden. Reduced motion shows one still frame. The animation is aria-hidden with a text description for screen readers. Chinese and Japanese transcripts appear three characters at a time. - Add 404.html so unknown paths stop falling back to the landing page, delete the duplicate accept-invite.html (/accept-invite/ is canonical) and link it straight to /#download, and move the legal pages off the retired orange accent (primary button contrast 5.2:1).
This commit is contained in:
parent
dc43884e3e
commit
92978607da
23 changed files with 707 additions and 467 deletions
25
site/public/404.html
Normal file
25
site/public/404.html
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<meta name="robots" content="noindex">
|
||||||
|
<title>페이지를 찾을 수 없습니다 — D3RO Voice</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/legal.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="shell">
|
||||||
|
<a class="brand" href="/"><span class="brand-mark"></span>D3RO VOICE</a>
|
||||||
|
<main>
|
||||||
|
<h1>페이지를 찾을 수 없습니다</h1>
|
||||||
|
<p class="lead">주소가 바뀌었거나 없는 페이지입니다.</p>
|
||||||
|
<p lang="en" class="meta">This page does not exist or has moved.</p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="button" href="/">첫 화면으로</a>
|
||||||
|
<a class="button secondary" href="/#download">다운로드</a>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,76 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html lang="ko">
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
||||||
<meta name="color-scheme" content="dark" />
|
|
||||||
<meta name="theme-color" content="#08090c" />
|
|
||||||
<meta name="robots" content="noindex,nofollow" />
|
|
||||||
<meta name="referrer" content="no-referrer" />
|
|
||||||
<meta
|
|
||||||
http-equiv="Content-Security-Policy"
|
|
||||||
content="default-src 'self'; base-uri 'none'; connect-src 'none'; font-src 'self'; form-action 'none'; img-src 'self' data:; object-src 'none'; script-src 'self'; style-src 'self'"
|
|
||||||
/>
|
|
||||||
<title>D3RO Voice — 팀 초대 열기</title>
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<link rel="stylesheet" href="/accept-invite.css" />
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<main class="shell">
|
|
||||||
<section class="invite-panel" aria-labelledby="invite-title">
|
|
||||||
<header class="brand-row">
|
|
||||||
<a class="brand" href="/" aria-label="D3RO Voice 홈">D3RO VOICE</a>
|
|
||||||
<span class="protocol"><span class="signal" aria-hidden="true"></span>SECURE INVITE</span>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="route-line" aria-hidden="true">
|
|
||||||
<span class="route-node route-node--active"></span>
|
|
||||||
<span class="route-track"></span>
|
|
||||||
<span class="route-node"></span>
|
|
||||||
<span class="route-track"></span>
|
|
||||||
<span class="route-node"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="copy-block">
|
|
||||||
<p class="eyebrow">TEAM ACCESS / MOBILE HANDOFF</p>
|
|
||||||
<h1 id="invite-title">D3RO Voice 앱에서<br />팀 초대를 확인해.</h1>
|
|
||||||
<p class="description">
|
|
||||||
초대 수락은 로그인한 계정과 서버 권한을 확인한 뒤에만 완료돼. 이 페이지는 초대 토큰을
|
|
||||||
저장하거나 수락 결과를 만들지 않아.
|
|
||||||
</p>
|
|
||||||
<p class="description description--en" lang="en">
|
|
||||||
Acceptance is completed only after the app verifies your signed-in account and server permissions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<dl class="token-card" aria-label="초대 링크 상태">
|
|
||||||
<div>
|
|
||||||
<dt>LINK STATUS</dt>
|
|
||||||
<dd id="invite-status" aria-live="polite">검증 중</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>TOKEN FINGERPRINT</dt>
|
|
||||||
<dd id="token-fingerprint">—</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
<p id="invite-error" class="error" role="alert" hidden></p>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<a id="open-app" class="button button--primary" href="#" aria-disabled="true">
|
|
||||||
D3RO Voice 앱 열기
|
|
||||||
</a>
|
|
||||||
<button id="copy-link" class="button button--secondary" type="button" disabled>
|
|
||||||
초대 링크 복사
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer class="support-row">
|
|
||||||
<span>앱이 설치되지 않았어?</span>
|
|
||||||
<a href="/download.html">안전한 설치 파일 받기</a>
|
|
||||||
</footer>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
<script src="/accept-invite.js" defer></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
@ -67,7 +67,7 @@
|
||||||
|
|
||||||
<footer class="support-row">
|
<footer class="support-row">
|
||||||
<span>앱이 설치되지 않았어?</span>
|
<span>앱이 설치되지 않았어?</span>
|
||||||
<a href="/download.html">안전한 설치 파일 받기</a>
|
<a href="/#download">안전한 설치 파일 받기</a>
|
||||||
</footer>
|
</footer>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,8 @@ body {
|
||||||
#090b0f;
|
#090b0f;
|
||||||
}
|
}
|
||||||
|
|
||||||
a { color: #ff8a62; }
|
a { color: #60a5fa; }
|
||||||
a:hover { color: #ffad91; }
|
a:hover { color: #93c5fd; }
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
width: min(920px, calc(100% - 32px));
|
width: min(920px, calc(100% - 32px));
|
||||||
|
|
@ -54,7 +54,7 @@ main {
|
||||||
|
|
||||||
.eyebrow {
|
.eyebrow {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
color: #ff8a62;
|
color: #60a5fa;
|
||||||
font: 700 11px/1.4 "JetBrains Mono", monospace;
|
font: 700 11px/1.4 "JetBrains Mono", monospace;
|
||||||
letter-spacing: .14em;
|
letter-spacing: .14em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|
@ -85,12 +85,12 @@ ul, ol { padding-left: 22px; }
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: #3b82f6;
|
background: #2563eb;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.button:hover { background: #ff6b38; color: #fff; }
|
.button:hover { background: #1d4ed8; color: #fff; }
|
||||||
.button.secondary { background: #282d37; color: #f3f4f6; }
|
.button.secondary { background: #282d37; color: #f3f4f6; }
|
||||||
|
|
||||||
footer {
|
footer {
|
||||||
|
|
|
||||||
260
site/src/components/AppLoopDemo.tsx
Normal file
260
site/src/components/AppLoopDemo.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useI18n } from '../i18n'
|
||||||
|
import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion'
|
||||||
|
import { PauseIcon, PlayIcon } from './icons'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 히어로 오른쪽의 반복 예시. 데스크톱 앱의 녹음 캡슐
|
||||||
|
* (apps/desktop/src/renderer/popups/recording-tip)을 같은 수치로 재현한다.
|
||||||
|
*
|
||||||
|
* 대기 → 오른쪽 Alt 누른 채 말하기(캡슐·파형·경과 시간·부분 전사)
|
||||||
|
* → 키를 떼면 처리 중(점근 진행 막대) → 커서 자리에 다듬어진 문장 입력 → 다음 예시
|
||||||
|
*
|
||||||
|
* WCAG 2.2.2: 5초 넘게 움직이므로 일시정지 버튼을 둔다. 화면 밖이거나 탭이 숨겨지면 멈춘다.
|
||||||
|
* 감소 모션이면 반복하지 않고 한 장면(말한 내용과 입력 결과)을 정지 화면으로 보여 준다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Phase = 'idle' | 'listening' | 'thinking' | 'typed'
|
||||||
|
|
||||||
|
// 앱 recording-tip/script.js 와 같은 값
|
||||||
|
const BAR_COUNT = 9
|
||||||
|
const BAR_INTERVAL_MS = 100
|
||||||
|
const BAR_MIN = 2
|
||||||
|
const BAR_MAX = 28
|
||||||
|
const SMOOTHING = 0.5
|
||||||
|
const RANDOM_FACTOR = 0.35
|
||||||
|
const BAR_WEIGHTS = Array.from({ length: BAR_COUNT }, (_, i) => {
|
||||||
|
const center = (BAR_COUNT - 1) / 2
|
||||||
|
return Math.cos(((i - center) / center) * (Math.PI / 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
// 장면 길이(ms)
|
||||||
|
const IDLE_MS = 1400
|
||||||
|
const WORD_MS = 260
|
||||||
|
const LISTEN_TAIL_MS = 700
|
||||||
|
const THINK_MS = 1100
|
||||||
|
const TYPED_MS = 3200
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 부분 전사가 나타나는 단위. 띄어 쓰는 언어는 낱말(뒤 공백 포함), 띄어 쓰지 않는
|
||||||
|
* 중국어·일본어는 세 글자씩 끊는다. 이어 붙이면 원문과 같다.
|
||||||
|
*/
|
||||||
|
function speechChunks(text: string): string[] {
|
||||||
|
if (/\s/.test(text)) return text.match(/\S+\s*/g) ?? [text]
|
||||||
|
const chars = Array.from(text)
|
||||||
|
const chunks: string[] = []
|
||||||
|
for (let i = 0; i < chars.length; i += 3) chunks.push(chars.slice(i, i + 3).join(''))
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
const s = Math.floor(ms / 1000)
|
||||||
|
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppLoopDemo() {
|
||||||
|
const { t } = useI18n()
|
||||||
|
const reducedMotion = usePrefersReducedMotion()
|
||||||
|
|
||||||
|
const [phase, setPhase] = useState<Phase>('idle')
|
||||||
|
const [sampleIndex, setSampleIndex] = useState(0)
|
||||||
|
const [wordCount, setWordCount] = useState(0)
|
||||||
|
const [elapsed, setElapsed] = useState(0)
|
||||||
|
const [userPaused, setUserPaused] = useState(false)
|
||||||
|
const [offscreen, setOffscreen] = useState(false)
|
||||||
|
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null)
|
||||||
|
const barRefs = useRef<(HTMLSpanElement | null)[]>([])
|
||||||
|
const progressRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const timers = useRef<number[]>([])
|
||||||
|
|
||||||
|
const running = !reducedMotion && !userPaused && !offscreen
|
||||||
|
const sample = t.demo.samples[sampleIndex % t.demo.samples.length]
|
||||||
|
const words = speechChunks(sample.raw)
|
||||||
|
|
||||||
|
const clearTimers = useCallback(() => {
|
||||||
|
timers.current.forEach((id) => window.clearTimeout(id))
|
||||||
|
timers.current = []
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 화면 밖·숨은 탭에서는 멈춘다.
|
||||||
|
useEffect(() => {
|
||||||
|
const el = rootRef.current
|
||||||
|
if (!el) return
|
||||||
|
const observer = new IntersectionObserver(([entry]) => setOffscreen(!entry.isIntersecting), { threshold: 0.2 })
|
||||||
|
observer.observe(el)
|
||||||
|
const onVisibility = () => setOffscreen(document.hidden)
|
||||||
|
document.addEventListener('visibilitychange', onVisibility)
|
||||||
|
return () => {
|
||||||
|
observer.disconnect()
|
||||||
|
document.removeEventListener('visibilitychange', onVisibility)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// 언어가 바뀌면 처음부터.
|
||||||
|
useEffect(() => {
|
||||||
|
setSampleIndex(0)
|
||||||
|
setPhase('idle')
|
||||||
|
}, [t])
|
||||||
|
|
||||||
|
// 장면 진행. 멈추면 현재 예시의 처음으로 돌아가 다시 시작한다.
|
||||||
|
useEffect(() => {
|
||||||
|
clearTimers()
|
||||||
|
if (!running) return
|
||||||
|
|
||||||
|
const schedule = (fn: () => void, at: number) => {
|
||||||
|
timers.current.push(window.setTimeout(fn, at))
|
||||||
|
}
|
||||||
|
|
||||||
|
setPhase('idle')
|
||||||
|
setWordCount(0)
|
||||||
|
setElapsed(0)
|
||||||
|
|
||||||
|
const listenStart = IDLE_MS
|
||||||
|
const listenEnd = listenStart + words.length * WORD_MS + LISTEN_TAIL_MS
|
||||||
|
const thinkEnd = listenEnd + THINK_MS
|
||||||
|
const loopEnd = thinkEnd + TYPED_MS
|
||||||
|
|
||||||
|
schedule(() => setPhase('listening'), listenStart)
|
||||||
|
words.forEach((_, i) => schedule(() => setWordCount(i + 1), listenStart + (i + 1) * WORD_MS))
|
||||||
|
for (let s = 1000; s < listenEnd - listenStart; s += 1000) {
|
||||||
|
schedule(() => setElapsed(s), listenStart + s)
|
||||||
|
}
|
||||||
|
schedule(() => setPhase('thinking'), listenEnd)
|
||||||
|
schedule(() => setPhase('typed'), thinkEnd)
|
||||||
|
schedule(() => setSampleIndex((i) => i + 1), loopEnd)
|
||||||
|
|
||||||
|
return clearTimers
|
||||||
|
// sampleIndex가 바뀌면 다음 예시로 다시 짠다.
|
||||||
|
}, [running, sampleIndex, t, clearTimers]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// 파형: 앱과 같은 알고리즘(cos 가중치 × 레벨 × 무작위, 스무딩)으로 100ms마다 갱신.
|
||||||
|
useEffect(() => {
|
||||||
|
const bars = barRefs.current
|
||||||
|
const heights = new Array<number>(BAR_COUNT).fill(BAR_MIN)
|
||||||
|
const paint = (level: number, jitter: boolean) => {
|
||||||
|
bars.forEach((bar, i) => {
|
||||||
|
if (!bar) return
|
||||||
|
const base = Math.max(0.08, level) * BAR_MAX * BAR_WEIGHTS[i]
|
||||||
|
const randomized = jitter ? base * (1 + (Math.random() - 0.5) * 2 * RANDOM_FACTOR) : base
|
||||||
|
const target = Math.max(BAR_MIN, Math.min(BAR_MAX, randomized))
|
||||||
|
heights[i] = jitter ? heights[i] + (target - heights[i]) * SMOOTHING : target
|
||||||
|
bar.style.height = `${Math.round(heights[i])}px`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!running || phase !== 'listening') {
|
||||||
|
paint(reducedMotion ? 0.7 : 0.08, false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let tick = 0
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
tick += 1
|
||||||
|
// 말소리처럼 음절마다 오르내리는 합성 레벨
|
||||||
|
const syllable = 0.5 + 0.5 * Math.sin(tick * 1.9)
|
||||||
|
paint(0.35 + 0.55 * syllable * (0.6 + 0.4 * Math.random()), true)
|
||||||
|
}, BAR_INTERVAL_MS)
|
||||||
|
return () => window.clearInterval(id)
|
||||||
|
}, [phase, running, reducedMotion])
|
||||||
|
|
||||||
|
// 처리 중 진행 막대: min(95, (1 - 1/(1 + 1.5t)) * 100)%
|
||||||
|
useEffect(() => {
|
||||||
|
const bar = progressRef.current
|
||||||
|
if (!bar || phase !== 'thinking') return
|
||||||
|
const start = performance.now()
|
||||||
|
let raf = 0
|
||||||
|
const step = () => {
|
||||||
|
const sec = (performance.now() - start) / 1000
|
||||||
|
bar.style.width = `${Math.min(95, (1 - 1 / (1 + 1.5 * sec)) * 100)}%`
|
||||||
|
if (running) raf = requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
raf = requestAnimationFrame(step)
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [phase, running])
|
||||||
|
|
||||||
|
// 감소 모션: 한 장면을 정지 화면으로.
|
||||||
|
const shownPhase: Phase = reducedMotion ? 'typed' : phase
|
||||||
|
const capsulePhase: Phase = reducedMotion ? 'listening' : phase
|
||||||
|
const partial = reducedMotion ? sample.raw : words.slice(0, wordCount).join('')
|
||||||
|
const keyDown = !reducedMotion && capsulePhase === 'listening'
|
||||||
|
|
||||||
|
const statusText = {
|
||||||
|
idle: t.demo.stateIdle,
|
||||||
|
listening: t.demo.stateListening,
|
||||||
|
thinking: t.demo.statePolishing,
|
||||||
|
typed: t.demo.stateDone,
|
||||||
|
}[reducedMotion ? 'typed' : phase]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<figure ref={rootRef} className="relative" aria-label={t.demo.label}>
|
||||||
|
<p className="sr-only">{t.demo.description}</p>
|
||||||
|
|
||||||
|
<div aria-hidden="true">
|
||||||
|
{/* 입력 대상 앱(메모) */}
|
||||||
|
<div className="demo-window">
|
||||||
|
<div className="demo-titlebar">
|
||||||
|
<span className="demo-app-icon" />
|
||||||
|
<span className="truncate">{t.demo.windowTitle}</span>
|
||||||
|
<span className="ml-auto flex items-center gap-3.5 text-neutral-500">
|
||||||
|
<span className="demo-win-glyph">─</span>
|
||||||
|
<span className="demo-win-glyph">□</span>
|
||||||
|
<span className="demo-win-glyph">✕</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="demo-doc keep-ko">
|
||||||
|
<p className="text-neutral-400">{t.demo.docLine}</p>
|
||||||
|
<p className="mt-3 text-neutral-50">
|
||||||
|
{shownPhase === 'typed' && <span className="demo-typed">{sample.clean}</span>}
|
||||||
|
<span className="demo-caret" />
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 녹음 캡슐 — 앱과 같은 자리(화면 아래 가운데)에 뜬다 */}
|
||||||
|
<div className={`demo-capsule-slot ${capsulePhase === 'listening' || capsulePhase === 'thinking' ? 'is-visible' : ''}`}>
|
||||||
|
<div className="capsule">
|
||||||
|
{capsulePhase === 'thinking' ? (
|
||||||
|
<div className="capsule-row">
|
||||||
|
<span className="capsule-progress"><span ref={progressRef} className="capsule-progress-bar" /></span>
|
||||||
|
<span className="capsule-thinking">{t.demo.processing}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="capsule-recording">
|
||||||
|
<div className="capsule-row justify-center">
|
||||||
|
<span className="capsule-bars">
|
||||||
|
{BAR_WEIGHTS.map((_, i) => (
|
||||||
|
<span key={i} ref={(el) => { barRefs.current[i] = el }} className="capsule-bar" />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
<span className="capsule-duration">{formatDuration(reducedMotion ? 3000 : elapsed)}</span>
|
||||||
|
</div>
|
||||||
|
{partial && <p className="capsule-partial keep-ko">{partial}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 누르고 있는 키 */}
|
||||||
|
<div className="mt-5 flex items-center gap-3 text-sm text-neutral-300">
|
||||||
|
<kbd className={`kbd transition-transform duration-100 ${keyDown ? 'kbd-accent translate-y-px' : ''}`}>{t.hero.hotkeyKey}</kbd>
|
||||||
|
<span className="keep-ko">{statusText}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<figcaption className="mt-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-3 text-xs text-neutral-400">
|
||||||
|
<span className="keep-ko">{t.demo.note}</span>
|
||||||
|
{!reducedMotion && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setUserPaused((p) => !p)}
|
||||||
|
aria-pressed={userPaused}
|
||||||
|
className="btn btn-secondary btn-sm"
|
||||||
|
>
|
||||||
|
{userPaused ? <PlayIcon /> : <PauseIcon />}
|
||||||
|
{userPaused ? t.demo.play : t.demo.pause}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
interface CrosshairProps {
|
|
||||||
className?: string
|
|
||||||
children: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Crosshair({ className = '', children }: CrosshairProps) {
|
|
||||||
return (
|
|
||||||
<div className={`crosshair-box ${className}`}>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
|
||||||
import { Crosshair } from './Crosshair'
|
|
||||||
import { Led } from './Led'
|
|
||||||
import { WaveBars } from './WaveBars'
|
|
||||||
import { PlayIcon } from './icons'
|
|
||||||
import { useI18n } from '../i18n'
|
|
||||||
import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion'
|
|
||||||
|
|
||||||
type Phase = 'idle' | 'listening' | 'polishing' | 'done'
|
|
||||||
|
|
||||||
const TYPE_INTERVAL_MS = 40
|
|
||||||
const POLISH_DELAY_MS = 700
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 받아쓰기 과정을 보여 주는 스크립트 예시. 실제 녹음은 하지 않으며 그 사실을 화면에 적는다.
|
|
||||||
* 감소 모션이면 타이핑 연출 없이 결과로 바로 간다.
|
|
||||||
*/
|
|
||||||
export function DictationDemo() {
|
|
||||||
const { t } = useI18n()
|
|
||||||
const reducedMotion = usePrefersReducedMotion()
|
|
||||||
const [phase, setPhase] = useState<Phase>('idle')
|
|
||||||
const [typed, setTyped] = useState('')
|
|
||||||
const timers = useRef<number[]>([])
|
|
||||||
|
|
||||||
const clearTimers = () => {
|
|
||||||
timers.current.forEach((id) => window.clearTimeout(id))
|
|
||||||
timers.current = []
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => clearTimers, [])
|
|
||||||
|
|
||||||
// 언어가 바뀌면 예시 문장이 달라지므로 처음 상태로 돌린다.
|
|
||||||
useEffect(() => {
|
|
||||||
clearTimers()
|
|
||||||
setPhase('idle')
|
|
||||||
setTyped('')
|
|
||||||
}, [t])
|
|
||||||
|
|
||||||
const play = () => {
|
|
||||||
clearTimers()
|
|
||||||
const raw = t.demo.sampleRaw
|
|
||||||
if (reducedMotion) {
|
|
||||||
setTyped(raw)
|
|
||||||
setPhase('done')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setTyped('')
|
|
||||||
setPhase('listening')
|
|
||||||
const chars = Array.from(raw)
|
|
||||||
chars.forEach((_, i) => {
|
|
||||||
timers.current.push(window.setTimeout(() => setTyped(chars.slice(0, i + 1).join('')), i * TYPE_INTERVAL_MS))
|
|
||||||
})
|
|
||||||
const heard = chars.length * TYPE_INTERVAL_MS
|
|
||||||
timers.current.push(window.setTimeout(() => setPhase('polishing'), heard))
|
|
||||||
timers.current.push(window.setTimeout(() => setPhase('done'), heard + POLISH_DELAY_MS))
|
|
||||||
}
|
|
||||||
|
|
||||||
const running = phase === 'listening' || phase === 'polishing'
|
|
||||||
const status = phase === 'idle' ? t.demo.title : t.demo[phase]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Crosshair>
|
|
||||||
<figure className="crt-screen rounded-2xl p-6 md:p-7">
|
|
||||||
<div className="relative z-10">
|
|
||||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 pb-4">
|
|
||||||
<p className="flex items-center gap-2 text-sm text-neutral-200" aria-live="polite">
|
|
||||||
<Led color={phase === 'done' ? 'green' : 'blue'} pulse={running} />
|
|
||||||
<span>{status}</span>
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { if (!running) play() }}
|
|
||||||
aria-disabled={running}
|
|
||||||
className="btn btn-secondary btn-sm aria-disabled:cursor-default aria-disabled:opacity-60"
|
|
||||||
>
|
|
||||||
<PlayIcon />
|
|
||||||
{phase === 'done' ? t.demo.replay : t.demo.play}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex h-12 items-center justify-center">
|
|
||||||
<WaveBars active={phase === 'listening'} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="min-h-[164px] space-y-3">
|
|
||||||
{phase === 'idle' ? (
|
|
||||||
<p className="keep-ko pt-6 text-center text-sm leading-relaxed text-neutral-400">{t.demo.idle}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="rounded-lg border border-white/10 bg-surface-950/80 p-3.5">
|
|
||||||
<p className="mb-1 text-xs text-neutral-400">{t.demo.rawLabel}</p>
|
|
||||||
<p className={`keep-ko text-sm leading-relaxed ${phase === 'done' ? 'text-neutral-400' : 'text-neutral-100'}`}>
|
|
||||||
{typed}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{phase === 'done' && (
|
|
||||||
<div className="rounded-lg border border-brand-blue/40 bg-brand-blue/10 p-3.5">
|
|
||||||
<p className="mb-1 text-xs text-brand-blue-light">{t.demo.cleanLabel}</p>
|
|
||||||
<p className="keep-ko text-base leading-relaxed text-neutral-50">{t.demo.sampleClean}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<figcaption className="mt-4 border-t border-white/10 pt-3 text-xs text-neutral-400">
|
|
||||||
{t.demo.note}
|
|
||||||
</figcaption>
|
|
||||||
</div>
|
|
||||||
</figure>
|
|
||||||
</Crosshair>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
import { useEffect, useRef } from 'react'
|
|
||||||
import { usePrefersReducedMotion } from '../hooks/usePrefersReducedMotion'
|
|
||||||
|
|
||||||
const BAR_COUNT = 9
|
|
||||||
// 녹음 UI와 같은 cos 분포: 가운데가 가장 높다.
|
|
||||||
const COS_WEIGHTS = Array.from({ length: BAR_COUNT }, (_, i) =>
|
|
||||||
Math.cos((i - 4) * (Math.PI / 9)),
|
|
||||||
)
|
|
||||||
const REST_HEIGHT = 4
|
|
||||||
const MAX_EXTRA = 28
|
|
||||||
|
|
||||||
interface WaveBarsProps {
|
|
||||||
/** 소리를 듣는 중일 때만 움직인다. 멈추면 낮은 정지 막대로 돌아간다. */
|
|
||||||
active: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WaveBars({ active }: WaveBarsProps) {
|
|
||||||
const barsRef = useRef<(HTMLSpanElement | null)[]>([])
|
|
||||||
const reducedMotion = usePrefersReducedMotion()
|
|
||||||
const animate = active && !reducedMotion
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const bars = barsRef.current
|
|
||||||
if (!animate) {
|
|
||||||
bars.forEach((bar, i) => {
|
|
||||||
if (bar) bar.style.height = `${active ? REST_HEIGHT + COS_WEIGHTS[i] * MAX_EXTRA * 0.6 : REST_HEIGHT}px`
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let frame = 0
|
|
||||||
let t = 0
|
|
||||||
const tick = () => {
|
|
||||||
t += 0.12
|
|
||||||
bars.forEach((bar, i) => {
|
|
||||||
if (!bar) return
|
|
||||||
const wave = Math.sin(t + i * 0.7) * 0.5 + 0.5
|
|
||||||
bar.style.height = `${REST_HEIGHT + COS_WEIGHTS[i] * wave * MAX_EXTRA}px`
|
|
||||||
})
|
|
||||||
frame = requestAnimationFrame(tick)
|
|
||||||
}
|
|
||||||
frame = requestAnimationFrame(tick)
|
|
||||||
return () => cancelAnimationFrame(frame)
|
|
||||||
}, [animate, active])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span className="flex h-9 items-center gap-1" aria-hidden="true">
|
|
||||||
{Array.from({ length: BAR_COUNT }, (_, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
ref={(el) => { barsRef.current[i] = el }}
|
|
||||||
className={`block w-1 rounded-full ${active ? 'bg-brand-blue-light' : 'bg-white/25'}`}
|
|
||||||
style={{ height: REST_HEIGHT }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -47,3 +47,12 @@ export function PlayIcon({ className = 'h-3.5 w-3.5' }: IconProps) {
|
||||||
</svg>
|
</svg>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PauseIcon({ className = 'h-3.5 w-3.5' }: IconProps) {
|
||||||
|
return (
|
||||||
|
<svg aria-hidden="true" className={className} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<rect x="6" y="4.5" width="4" height="15" rx="1" />
|
||||||
|
<rect x="14" y="4.5" width="4" height="15" rx="1" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,11 @@ export interface TitledText {
|
||||||
body: string
|
body: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DemoSample {
|
||||||
|
raw: string
|
||||||
|
clean: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface FaqItem {
|
export interface FaqItem {
|
||||||
q: string
|
q: string
|
||||||
a: string
|
a: string
|
||||||
|
|
@ -62,24 +67,28 @@ export interface Translations {
|
||||||
subtitle: string
|
subtitle: string
|
||||||
hotkeyKey: string
|
hotkeyKey: string
|
||||||
hotkeyAction: string
|
hotkeyAction: string
|
||||||
secondaryCta: string
|
|
||||||
trust: string
|
trust: string
|
||||||
/** 방문자가 Windows가 아닐 때 다운로드 버튼 아래에 보인다. */
|
/** 방문자가 Windows가 아닐 때 다운로드 버튼 아래에 보인다. */
|
||||||
windowsOnly: string
|
windowsOnly: string
|
||||||
}
|
}
|
||||||
demo: {
|
demo: {
|
||||||
title: string
|
/** figure 의 접근 가능한 이름 */
|
||||||
|
label: string
|
||||||
|
/** 화면 낭독용 설명. 애니메이션 자체는 aria-hidden 이다. */
|
||||||
|
description: string
|
||||||
note: string
|
note: string
|
||||||
idle: string
|
windowTitle: string
|
||||||
listening: string
|
docLine: string
|
||||||
polishing: string
|
/** 캡슐의 처리 중 문구 */
|
||||||
done: string
|
processing: string
|
||||||
|
stateIdle: string
|
||||||
|
stateListening: string
|
||||||
|
statePolishing: string
|
||||||
|
stateDone: string
|
||||||
|
pause: string
|
||||||
play: string
|
play: string
|
||||||
replay: string
|
/** raw 는 띄어쓰기 단위로 한 낱말씩 나타난다. */
|
||||||
rawLabel: string
|
samples: [DemoSample, DemoSample]
|
||||||
cleanLabel: string
|
|
||||||
sampleRaw: string
|
|
||||||
sampleClean: string
|
|
||||||
}
|
}
|
||||||
features: {
|
features: {
|
||||||
title: string
|
title: string
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const de: Translations = {
|
||||||
subtitle: 'Halten Sie die Tastenkombination gedrückt, sprechen Sie, lassen Sie los. Spracherkennung und Bereinigung laufen auf Ihrem PC, und das Ergebnis wird direkt in die App eingefügt, die Sie gerade verwenden.',
|
subtitle: 'Halten Sie die Tastenkombination gedrückt, sprechen Sie, lassen Sie los. Spracherkennung und Bereinigung laufen auf Ihrem PC, und das Ergebnis wird direkt in die App eingefügt, die Sie gerade verwenden.',
|
||||||
hotkeyKey: 'rechte Alt-Taste',
|
hotkeyKey: 'rechte Alt-Taste',
|
||||||
hotkeyAction: 'gedrückt halten zum Sprechen',
|
hotkeyAction: 'gedrückt halten zum Sprechen',
|
||||||
secondaryCta: 'Funktionsweise ansehen',
|
|
||||||
trust: 'Kostenlos starten · Kein Konto nötig · Funktioniert offline, sobald die Modelle heruntergeladen sind',
|
trust: 'Kostenlos starten · Kein Konto nötig · Funktioniert offline, sobald die Modelle heruntergeladen sind',
|
||||||
windowsOnly: 'Derzeit ist nur eine Windows-Version verfügbar. Öffnen Sie diese Seite auf einem Windows-PC, um sie herunterzuladen.',
|
windowsOnly: 'Derzeit ist nur eine Windows-Version verfügbar. Öffnen Sie diese Seite auf einem Windows-PC, um sie herunterzuladen.',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: 'Beispiel',
|
label: 'So funktioniert die App',
|
||||||
note: 'Dies ist ein vorbereitetes Beispiel. Es wird nichts aufgenommen.',
|
description: 'Wenn Sie die rechte Alt-Taste gedrückt halten und sprechen, erscheint unten am Bildschirm die Aufnahmekapsel, die Ihre Worte zeigt, während Sie sie sagen. Wenn Sie loslassen, wird der Satz bereinigt und an der Cursorposition im Notizfenster eingefügt. Das Beispiel wiederholt sich.',
|
||||||
idle: 'Drücken Sie auf Wiedergabe, um zu sehen, wie Sprache zu Text wird und bereinigt wird.',
|
note: 'Ein vorbereitetes Beispiel, keine echte Aufnahme.',
|
||||||
listening: 'Hört zu',
|
windowTitle: 'Notizen',
|
||||||
polishing: 'Wird bereinigt',
|
docLine: 'Notizen zum Team-Meeting am Freitag',
|
||||||
done: 'Eingefügt',
|
processing: 'Wird verarbeitet...',
|
||||||
|
stateIdle: 'Wartet',
|
||||||
|
stateListening: 'Gedrückt halten und sprechen',
|
||||||
|
statePolishing: 'Losgelassen, wird bereinigt',
|
||||||
|
stateDone: 'An der Cursorposition eingefügt',
|
||||||
|
pause: 'Beispiel pausieren',
|
||||||
play: 'Beispiel abspielen',
|
play: 'Beispiel abspielen',
|
||||||
replay: 'Erneut abspielen',
|
samples: [
|
||||||
rawLabel: 'Was gehört wurde',
|
{
|
||||||
cleanLabel: 'Was eingefügt wurde',
|
raw: 'ähm also ich denke wir können den Release quasi auf nächsten Freitag legen vielleicht',
|
||||||
sampleRaw: 'ähm also ich denke wir können den Release quasi auf nächsten Freitag legen vielleicht',
|
clean: 'Ich denke, wir können den Release für nächsten Freitag ansetzen.',
|
||||||
sampleClean: 'Ich denke, wir können den Release für nächsten Freitag ansetzen.',
|
},
|
||||||
|
{
|
||||||
|
raw: 'kannst du die Besprechungsnotizen aufräumen und heute noch in den Team-Channel stellen ach und die Anhänge auch',
|
||||||
|
clean: 'Bitte räume die Besprechungsnotizen auf und poste sie heute noch zusammen mit den Anhängen im Team-Channel.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: 'Von kurzen Notizen bis zum Besprechungsprotokoll, alles auf Ihrem PC.',
|
title: 'Von kurzen Notizen bis zum Besprechungsprotokoll, alles auf Ihrem PC.',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const en: Translations = {
|
||||||
subtitle: 'Hold the shortcut, talk, let go. Speech recognition and cleanup run on your PC, and the result is typed straight into the app you are already using.',
|
subtitle: 'Hold the shortcut, talk, let go. Speech recognition and cleanup run on your PC, and the result is typed straight into the app you are already using.',
|
||||||
hotkeyKey: 'Right Alt',
|
hotkeyKey: 'Right Alt',
|
||||||
hotkeyAction: 'hold to talk',
|
hotkeyAction: 'hold to talk',
|
||||||
secondaryCta: 'See how it works',
|
|
||||||
trust: 'Free to start · No account needed · Works offline once models are downloaded',
|
trust: 'Free to start · No account needed · Works offline once models are downloaded',
|
||||||
windowsOnly: 'Only a Windows version is available right now. Open this page on a Windows PC to download it.',
|
windowsOnly: 'Only a Windows version is available right now. Open this page on a Windows PC to download it.',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: 'Example',
|
label: 'How the app works',
|
||||||
note: 'This is a scripted example. Nothing is recorded.',
|
description: 'Holding Right Alt and speaking brings up the recording capsule at the bottom of the screen, which shows your words as you say them. When you let go, the sentence is cleaned up and typed at the cursor in the notes window. The example repeats.',
|
||||||
idle: 'Press play to see speech turn into text and get cleaned up.',
|
note: 'A scripted example, not a real recording.',
|
||||||
listening: 'Listening',
|
windowTitle: 'Notes',
|
||||||
polishing: 'Cleaning up',
|
docLine: 'Friday team meeting notes',
|
||||||
done: 'Typed',
|
processing: 'Processing...',
|
||||||
|
stateIdle: 'Waiting',
|
||||||
|
stateListening: 'Holding and speaking',
|
||||||
|
statePolishing: 'Released, cleaning up',
|
||||||
|
stateDone: 'Typed at the cursor',
|
||||||
|
pause: 'Pause example',
|
||||||
play: 'Play example',
|
play: 'Play example',
|
||||||
replay: 'Play again',
|
samples: [
|
||||||
rawLabel: 'What was heard',
|
{
|
||||||
cleanLabel: 'What was typed',
|
raw: 'uh so I think we can like put the release at next Friday maybe',
|
||||||
sampleRaw: 'uh so I think we can, like, put the release at next Friday maybe',
|
clean: 'I think we can schedule the release for next Friday.',
|
||||||
sampleClean: 'I think we can schedule the release for next Friday.',
|
},
|
||||||
|
{
|
||||||
|
raw: 'can you clean up the meeting notes and post them to the team channel today oh and the attachments too',
|
||||||
|
clean: 'Please clean up the meeting notes and post them, with the attachments, to the team channel today.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: 'From quick notes to meeting minutes, all on your PC.',
|
title: 'From quick notes to meeting minutes, all on your PC.',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const es: Translations = {
|
||||||
subtitle: 'Mantén presionado el atajo, habla, suelta. El reconocimiento de voz y la limpieza se ejecutan en tu PC, y el resultado se escribe directamente en la aplicación que ya estás usando.',
|
subtitle: 'Mantén presionado el atajo, habla, suelta. El reconocimiento de voz y la limpieza se ejecutan en tu PC, y el resultado se escribe directamente en la aplicación que ya estás usando.',
|
||||||
hotkeyKey: 'Alt derecho',
|
hotkeyKey: 'Alt derecho',
|
||||||
hotkeyAction: 'mantén presionado para hablar',
|
hotkeyAction: 'mantén presionado para hablar',
|
||||||
secondaryCta: 'Ver cómo funciona',
|
|
||||||
trust: 'Gratis para empezar · Sin necesidad de cuenta · Funciona sin conexión una vez descargados los modelos',
|
trust: 'Gratis para empezar · Sin necesidad de cuenta · Funciona sin conexión una vez descargados los modelos',
|
||||||
windowsOnly: 'Por ahora solo hay una versión para Windows. Abre esta página en una PC con Windows para descargarla.',
|
windowsOnly: 'Por ahora solo hay una versión para Windows. Abre esta página en una PC con Windows para descargarla.',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: 'Ejemplo',
|
label: 'Cómo funciona la app',
|
||||||
note: 'Este es un ejemplo guionado. No se graba nada.',
|
description: 'Al mantener presionado Alt derecho y hablar, aparece la cápsula de grabación en la parte inferior de la pantalla, que muestra tus palabras a medida que las dices. Al soltar, la frase se limpia y se escribe en el cursor de la ventana de notas. El ejemplo se repite.',
|
||||||
idle: 'Presiona reproducir para ver cómo el habla se convierte en texto y se limpia.',
|
note: 'Un ejemplo guionado, no una grabación real.',
|
||||||
listening: 'Escuchando',
|
windowTitle: 'Notas',
|
||||||
polishing: 'Limpiando',
|
docLine: 'Notas de la reunión de equipo del viernes',
|
||||||
done: 'Escrito',
|
processing: 'Procesando...',
|
||||||
|
stateIdle: 'Esperando',
|
||||||
|
stateListening: 'Manteniendo presionado y hablando',
|
||||||
|
statePolishing: 'Al soltar, limpiando',
|
||||||
|
stateDone: 'Escrito en el cursor',
|
||||||
|
pause: 'Pausar ejemplo',
|
||||||
play: 'Reproducir ejemplo',
|
play: 'Reproducir ejemplo',
|
||||||
replay: 'Reproducir de nuevo',
|
samples: [
|
||||||
rawLabel: 'Lo que se escuchó',
|
{
|
||||||
cleanLabel: 'Lo que se escribió',
|
raw: 'eh o sea creo que podemos, como, poner el lanzamiento el próximo viernes tal vez',
|
||||||
sampleRaw: 'eh o sea creo que podemos, como, poner el lanzamiento el próximo viernes tal vez',
|
clean: 'Creo que podemos programar el lanzamiento para el próximo viernes.',
|
||||||
sampleClean: 'Creo que podemos programar el lanzamiento para el próximo viernes.',
|
},
|
||||||
|
{
|
||||||
|
raw: 'oye puedes limpiar las notas de la reunión y subirlas al canal del equipo hoy ah y los archivos adjuntos también',
|
||||||
|
clean: 'Por favor, limpia las notas de la reunión y publícalas, junto con los archivos adjuntos, en el canal del equipo hoy.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: 'De notas rápidas a actas de reunión, todo en tu PC.',
|
title: 'De notas rápidas a actas de reunión, todo en tu PC.',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const fr: Translations = {
|
||||||
subtitle: 'Maintenez le raccourci, parlez, relâchez. La reconnaissance vocale et la mise en forme s\'exécutent sur votre PC, et le résultat est saisi directement dans l\'application que vous utilisez déjà.',
|
subtitle: 'Maintenez le raccourci, parlez, relâchez. La reconnaissance vocale et la mise en forme s\'exécutent sur votre PC, et le résultat est saisi directement dans l\'application que vous utilisez déjà.',
|
||||||
hotkeyKey: 'Alt de droite',
|
hotkeyKey: 'Alt de droite',
|
||||||
hotkeyAction: 'maintenir pour parler',
|
hotkeyAction: 'maintenir pour parler',
|
||||||
secondaryCta: 'Voir comment ça marche',
|
|
||||||
trust: 'Gratuit pour commencer · Aucun compte requis · Fonctionne hors ligne une fois les modèles téléchargés',
|
trust: 'Gratuit pour commencer · Aucun compte requis · Fonctionne hors ligne une fois les modèles téléchargés',
|
||||||
windowsOnly: 'Seule une version Windows est disponible pour l\'instant. Ouvrez cette page sur un PC Windows pour la télécharger.',
|
windowsOnly: 'Seule une version Windows est disponible pour l\'instant. Ouvrez cette page sur un PC Windows pour la télécharger.',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: 'Exemple',
|
label: 'Comment fonctionne l\'application',
|
||||||
note: 'Ceci est un exemple préparé à l\'avance. Rien n\'est enregistré.',
|
description: 'En maintenant Alt de droite enfoncé et en parlant, la capsule d\'enregistrement apparaît en bas de l\'écran et affiche vos mots au fur et à mesure. Quand vous relâchez, la phrase est mise en forme puis saisie à l\'emplacement du curseur dans la fenêtre de notes. L\'exemple se répète.',
|
||||||
idle: 'Appuyez sur lecture pour voir la parole se transformer en texte, puis être mise en forme.',
|
note: 'Un exemple scénarisé, pas un véritable enregistrement.',
|
||||||
listening: 'Écoute en cours',
|
windowTitle: 'Notes',
|
||||||
polishing: 'Mise en forme',
|
docLine: 'Notes de la réunion d\'équipe du vendredi',
|
||||||
done: 'Saisi',
|
processing: 'Traitement en cours...',
|
||||||
play: 'Lancer l\'exemple',
|
stateIdle: 'En attente',
|
||||||
replay: 'Rejouer',
|
stateListening: 'Maintien et parole',
|
||||||
rawLabel: 'Ce qui a été entendu',
|
statePolishing: 'Relâché, mise en forme',
|
||||||
cleanLabel: 'Ce qui a été saisi',
|
stateDone: 'Saisi à l\'emplacement du curseur',
|
||||||
sampleRaw: 'euh donc je pense qu\'on peut, genre, mettre la sortie vendredi prochain peut-être',
|
pause: 'Mettre l\'exemple en pause',
|
||||||
sampleClean: 'Je pense que nous pouvons prévoir la sortie pour vendredi prochain.',
|
play: 'Lire l\'exemple',
|
||||||
|
samples: [
|
||||||
|
{
|
||||||
|
raw: 'euh donc je pense qu\'on peut, genre, mettre la sortie vendredi prochain peut-être',
|
||||||
|
clean: 'Je pense que nous pouvons prévoir la sortie pour vendredi prochain.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
raw: 'tu peux nettoyer les notes de réunion et les publier sur le canal de l\'équipe aujourd\'hui ah et les pièces jointes aussi',
|
||||||
|
clean: 'Merci de nettoyer les notes de réunion et de les publier, avec les pièces jointes, sur le canal de l\'équipe aujourd\'hui.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: 'Des notes rapides aux comptes rendus de réunion, tout sur votre PC.',
|
title: 'Des notes rapides aux comptes rendus de réunion, tout sur votre PC.',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const ja: Translations = {
|
||||||
subtitle: 'ショートカットを押しながら話して、離すだけ。音声認識と文章の整形はPC内で処理され、結果はいま使っているアプリにそのまま入力されます。',
|
subtitle: 'ショートカットを押しながら話して、離すだけ。音声認識と文章の整形はPC内で処理され、結果はいま使っているアプリにそのまま入力されます。',
|
||||||
hotkeyKey: '右Alt',
|
hotkeyKey: '右Alt',
|
||||||
hotkeyAction: '押している間だけ話せる',
|
hotkeyAction: '押している間だけ話せる',
|
||||||
secondaryCta: '使い方を見る',
|
|
||||||
trust: '無料で開始 · アカウント不要 · モデルのダウンロード後はオフラインでも利用可能',
|
trust: '無料で開始 · アカウント不要 · モデルのダウンロード後はオフラインでも利用可能',
|
||||||
windowsOnly: '現在はWindows版のみ提供しています。ダウンロードするにはWindows PCでこのページを開いてください。',
|
windowsOnly: '現在はWindows版のみ提供しています。ダウンロードするにはWindows PCでこのページを開いてください。',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: 'デモ',
|
label: 'アプリの動作例',
|
||||||
note: 'これは台本による例です。実際には録音していません。',
|
description: '右Altを押しながら話すと、画面下部に録音カプセルが表示され、話した内容がその場で表示されます。キーを離すと文章が整えられ、メモ画面のカーソル位置に入力されます。この例は繰り返し再生されます。',
|
||||||
idle: '再生を押すと、話した内容がテキストになり整形される様子が見られます。',
|
note: '台本による例であり、実際の録音ではありません。',
|
||||||
listening: '聞き取り中',
|
windowTitle: 'メモ',
|
||||||
polishing: '整形中',
|
docLine: '金曜日のチーム会議メモ',
|
||||||
done: '入力完了',
|
processing: '処理中...',
|
||||||
play: 'デモを再生',
|
stateIdle: '待機中',
|
||||||
replay: 'もう一度再生',
|
stateListening: '押しながら話している',
|
||||||
rawLabel: '認識された内容',
|
statePolishing: '離すと整形',
|
||||||
cleanLabel: '入力された内容',
|
stateDone: 'カーソル位置に入力済み',
|
||||||
sampleRaw: 'えっと、リリースは来週の金曜日あたりでいいと思います',
|
pause: '例を一時停止',
|
||||||
sampleClean: 'リリースは来週の金曜日に予定しましょう。',
|
play: '例を再生',
|
||||||
|
samples: [
|
||||||
|
{
|
||||||
|
raw: 'えっとこのリリースなんですけど来週の金曜くらいでいいと思います',
|
||||||
|
clean: 'リリースは来週の金曜日に予定しましょう。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
raw: '会議のメモまとめて今日中にチームチャンネルに上げといてもらえますかあと添付ファイルもお願いします',
|
||||||
|
clean: '会議のメモを整理して、添付ファイルと一緒に今日中にチームチャンネルへ投稿してください。',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: 'ちょっとしたメモから議事録まで、すべてPCの中で。',
|
title: 'ちょっとしたメモから議事録まで、すべてPCの中で。',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const ko: Translations = {
|
||||||
subtitle: '단축키를 누른 채 말하고 떼세요. 음성 인식과 문장 다듬기가 내 PC에서 실행되고, 결과는 지금 쓰고 있는 앱에 바로 입력됩니다.',
|
subtitle: '단축키를 누른 채 말하고 떼세요. 음성 인식과 문장 다듬기가 내 PC에서 실행되고, 결과는 지금 쓰고 있는 앱에 바로 입력됩니다.',
|
||||||
hotkeyKey: '오른쪽 Alt',
|
hotkeyKey: '오른쪽 Alt',
|
||||||
hotkeyAction: '누른 채 말하기',
|
hotkeyAction: '누른 채 말하기',
|
||||||
secondaryCta: '작동 방식 보기',
|
|
||||||
trust: '무료로 시작 · 계정 없이 사용 · 모델을 받은 뒤에는 인터넷 없이 동작',
|
trust: '무료로 시작 · 계정 없이 사용 · 모델을 받은 뒤에는 인터넷 없이 동작',
|
||||||
windowsOnly: '지금은 Windows용만 제공합니다. Windows PC에서 이 페이지를 열어 받아 주세요.',
|
windowsOnly: '지금은 Windows용만 제공합니다. Windows PC에서 이 페이지를 열어 받아 주세요.',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: '예시 화면',
|
label: '앱 동작 예시',
|
||||||
note: '실제로 녹음하지 않는 예시입니다.',
|
description: '오른쪽 Alt를 누른 채 말하면 화면 아래에 녹음 캡슐이 뜨고 말한 내용이 실시간으로 보입니다. 키를 떼면 문장을 다듬어 메모 창의 커서 자리에 입력합니다. 이 과정이 반복 재생됩니다.',
|
||||||
idle: '재생을 누르면 말이 글로 바뀌고 다듬어지는 과정을 보여 드립니다.',
|
note: '실제 녹음이 아닌 예시 화면입니다.',
|
||||||
listening: '듣는 중',
|
windowTitle: '메모',
|
||||||
polishing: '다듬는 중',
|
docLine: '금요일 팀 회의 메모',
|
||||||
done: '입력 완료',
|
processing: '처리 중...',
|
||||||
|
stateIdle: '대기 중',
|
||||||
|
stateListening: '누른 채 말하는 중',
|
||||||
|
statePolishing: '키를 떼면 다듬기',
|
||||||
|
stateDone: '커서 자리에 입력됨',
|
||||||
|
pause: '예시 일시정지',
|
||||||
play: '예시 재생',
|
play: '예시 재생',
|
||||||
replay: '다시 재생',
|
samples: [
|
||||||
rawLabel: '인식된 말',
|
{
|
||||||
cleanLabel: '입력된 문장',
|
raw: '어… 그러니까 이번 배포는 다음 주 금요일까지로 잡으면 될 것 같아요',
|
||||||
sampleRaw: '어… 이번 배포는 그러니까 다음 주 금요일까지로 잡으면 될 것 같아요',
|
clean: '이번 배포는 다음 주 금요일까지로 잡겠습니다.',
|
||||||
sampleClean: '이번 배포는 다음 주 금요일까지로 잡겠습니다.',
|
},
|
||||||
|
{
|
||||||
|
raw: '회의록 정리해서 오늘 중으로 팀 채널에 올려 주세요 아 첨부 파일도 같이요',
|
||||||
|
clean: '회의록을 정리해 오늘 중으로 첨부 파일과 함께 팀 채널에 올려 주세요.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: '받아쓰기부터 회의 기록까지, 내 PC 안에서.',
|
title: '받아쓰기부터 회의 기록까지, 내 PC 안에서.',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const pt: Translations = {
|
||||||
subtitle: 'Segure o atalho, fale, solte. O reconhecimento de voz e a limpeza rodam no seu PC, e o resultado é digitado direto no aplicativo que você já está usando.',
|
subtitle: 'Segure o atalho, fale, solte. O reconhecimento de voz e a limpeza rodam no seu PC, e o resultado é digitado direto no aplicativo que você já está usando.',
|
||||||
hotkeyKey: 'Alt direito',
|
hotkeyKey: 'Alt direito',
|
||||||
hotkeyAction: 'segure para falar',
|
hotkeyAction: 'segure para falar',
|
||||||
secondaryCta: 'Ver como funciona',
|
|
||||||
trust: 'Grátis para começar · Sem necessidade de conta · Funciona offline depois de baixar os modelos',
|
trust: 'Grátis para começar · Sem necessidade de conta · Funciona offline depois de baixar os modelos',
|
||||||
windowsOnly: 'No momento só há uma versão para Windows. Abra esta página em um PC com Windows para baixá-la.',
|
windowsOnly: 'No momento só há uma versão para Windows. Abra esta página em um PC com Windows para baixá-la.',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: 'Exemplo',
|
label: 'Como o app funciona',
|
||||||
note: 'Este é um exemplo roteirizado. Nada é gravado.',
|
description: 'Ao segurar Alt direito e falar, aparece a cápsula de gravação na parte inferior da tela, mostrando suas palavras enquanto você fala. Ao soltar, a frase é corrigida e digitada no cursor da janela de notas. O exemplo se repete.',
|
||||||
idle: 'Aperte reproduzir para ver a fala virar texto e ser corrigida.',
|
note: 'Um exemplo roteirizado, não uma gravação real.',
|
||||||
listening: 'Ouvindo',
|
windowTitle: 'Notas',
|
||||||
polishing: 'Corrigindo',
|
docLine: 'Notas da reunião de equipe de sexta-feira',
|
||||||
done: 'Digitado',
|
processing: 'Processando...',
|
||||||
|
stateIdle: 'Aguardando',
|
||||||
|
stateListening: 'Segurando e falando',
|
||||||
|
statePolishing: 'Solto, corrigindo',
|
||||||
|
stateDone: 'Digitado no cursor',
|
||||||
|
pause: 'Pausar exemplo',
|
||||||
play: 'Reproduzir exemplo',
|
play: 'Reproduzir exemplo',
|
||||||
replay: 'Reproduzir de novo',
|
samples: [
|
||||||
rawLabel: 'O que foi ouvido',
|
{
|
||||||
cleanLabel: 'O que foi digitado',
|
raw: 'é... então acho que a gente pode, tipo, marcar o lançamento pra próxima sexta talvez',
|
||||||
sampleRaw: 'é... então acho que a gente pode, tipo, marcar o lançamento pra próxima sexta talvez',
|
clean: 'Acho que podemos agendar o lançamento para a próxima sexta-feira.',
|
||||||
sampleClean: 'Acho que podemos agendar o lançamento para a próxima sexta-feira.',
|
},
|
||||||
|
{
|
||||||
|
raw: 'dá pra organizar as notas da reunião e postar no canal da equipe hoje ah e os anexos também',
|
||||||
|
clean: 'Organize as notas da reunião e publique-as, com os anexos, no canal da equipe hoje.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: 'De anotações rápidas a atas de reunião, tudo no seu PC.',
|
title: 'De anotações rápidas a atas de reunião, tudo no seu PC.',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const ru: Translations = {
|
||||||
subtitle: 'Зажмите сочетание клавиш, скажите и отпустите. Распознавание речи и очистка текста выполняются на вашем ПК, а результат сразу вставляется в то приложение, с которым вы работаете.',
|
subtitle: 'Зажмите сочетание клавиш, скажите и отпустите. Распознавание речи и очистка текста выполняются на вашем ПК, а результат сразу вставляется в то приложение, с которым вы работаете.',
|
||||||
hotkeyKey: 'Правый Alt',
|
hotkeyKey: 'Правый Alt',
|
||||||
hotkeyAction: 'зажмите, чтобы говорить',
|
hotkeyAction: 'зажмите, чтобы говорить',
|
||||||
secondaryCta: 'Посмотреть, как это работает',
|
|
||||||
trust: 'Бесплатный старт · Без регистрации · Работает офлайн после загрузки моделей',
|
trust: 'Бесплатный старт · Без регистрации · Работает офлайн после загрузки моделей',
|
||||||
windowsOnly: 'Сейчас доступна только версия для Windows. Откройте эту страницу на компьютере с Windows, чтобы скачать приложение.',
|
windowsOnly: 'Сейчас доступна только версия для Windows. Откройте эту страницу на компьютере с Windows, чтобы скачать приложение.',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: 'Пример',
|
label: 'Как работает приложение',
|
||||||
note: 'Это заранее подготовленный пример. Ничего не записывается.',
|
description: 'Если зажать правый Alt и говорить, внизу экрана появляется капсула записи, которая показывает ваши слова по мере произнесения. Когда вы отпускаете клавишу, предложение очищается и вводится в позицию курсора в окне заметок. Пример повторяется.',
|
||||||
idle: 'Нажмите «Воспроизвести», чтобы увидеть, как речь превращается в текст и очищается.',
|
note: 'Заранее подготовленный пример, а не настоящая запись.',
|
||||||
listening: 'Слушаю',
|
windowTitle: 'Заметки',
|
||||||
polishing: 'Очищаю',
|
docLine: 'Заметки с пятничной встречи команды',
|
||||||
done: 'Введено',
|
processing: 'Обработка...',
|
||||||
|
stateIdle: 'Ожидание',
|
||||||
|
stateListening: 'Зажато, говорю',
|
||||||
|
statePolishing: 'Отпущено, идёт очистка',
|
||||||
|
stateDone: 'Введено в позицию курсора',
|
||||||
|
pause: 'Приостановить пример',
|
||||||
play: 'Воспроизвести пример',
|
play: 'Воспроизвести пример',
|
||||||
replay: 'Воспроизвести ещё раз',
|
samples: [
|
||||||
rawLabel: 'Что было услышано',
|
{
|
||||||
cleanLabel: 'Что было введено',
|
raw: 'э-э, ну я думаю, можно, типа, поставить релиз на следующую пятницу, наверное',
|
||||||
sampleRaw: 'э-э, ну я думаю, можно, типа, поставить релиз на следующую пятницу, наверное',
|
clean: 'Думаю, мы можем назначить релиз на следующую пятницу.',
|
||||||
sampleClean: 'Думаю, мы можем назначить релиз на следующую пятницу.',
|
},
|
||||||
|
{
|
||||||
|
raw: 'слушай приведи в порядок заметки со встречи и выложи их в канал команды сегодня а ещё вложения тоже',
|
||||||
|
clean: 'Пожалуйста, приведи в порядок заметки со встречи и выложи их вместе с вложениями в канал команды сегодня.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: 'От быстрых заметок до протоколов встреч — и всё на вашем ПК.',
|
title: 'От быстрых заметок до протоколов встреч — и всё на вашем ПК.',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const vi: Translations = {
|
||||||
subtitle: 'Giữ phím tắt, nói, rồi thả ra. Nhận diện giọng nói và làm sạch văn bản chạy trên máy tính của bạn, kết quả được gõ thẳng vào ứng dụng bạn đang dùng.',
|
subtitle: 'Giữ phím tắt, nói, rồi thả ra. Nhận diện giọng nói và làm sạch văn bản chạy trên máy tính của bạn, kết quả được gõ thẳng vào ứng dụng bạn đang dùng.',
|
||||||
hotkeyKey: 'Alt phải',
|
hotkeyKey: 'Alt phải',
|
||||||
hotkeyAction: 'giữ để nói',
|
hotkeyAction: 'giữ để nói',
|
||||||
secondaryCta: 'Xem cách hoạt động',
|
|
||||||
trust: 'Miễn phí để bắt đầu · Không cần tài khoản · Hoạt động offline sau khi tải mô hình',
|
trust: 'Miễn phí để bắt đầu · Không cần tài khoản · Hoạt động offline sau khi tải mô hình',
|
||||||
windowsOnly: 'Hiện chỉ có phiên bản Windows. Hãy mở trang này trên máy tính Windows để tải về.',
|
windowsOnly: 'Hiện chỉ có phiên bản Windows. Hãy mở trang này trên máy tính Windows để tải về.',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: 'Ví dụ',
|
label: 'Cách ứng dụng hoạt động',
|
||||||
note: 'Đây là ví dụ dựng sẵn. Không có gì được ghi âm.',
|
description: 'Khi giữ Alt phải và nói, viên nang ghi âm hiện ra ở dưới màn hình, hiển thị lời bạn nói ngay khi bạn nói. Khi thả phím, câu được làm sạch và gõ vào vị trí con trỏ trong cửa sổ ghi chú. Ví dụ này lặp lại.',
|
||||||
idle: 'Nhấn phát để xem lời nói biến thành chữ và được làm sạch.',
|
note: 'Đây là ví dụ dựng sẵn, không phải bản ghi âm thật.',
|
||||||
listening: 'Đang nghe',
|
windowTitle: 'Ghi chú',
|
||||||
polishing: 'Đang làm sạch',
|
docLine: 'Ghi chú họp nhóm thứ Sáu',
|
||||||
done: 'Đã gõ xong',
|
processing: 'Đang xử lý...',
|
||||||
|
stateIdle: 'Đang chờ',
|
||||||
|
stateListening: 'Đang giữ và nói',
|
||||||
|
statePolishing: 'Đã thả, đang làm sạch',
|
||||||
|
stateDone: 'Đã gõ vào vị trí con trỏ',
|
||||||
|
pause: 'Tạm dừng ví dụ',
|
||||||
play: 'Phát ví dụ',
|
play: 'Phát ví dụ',
|
||||||
replay: 'Phát lại',
|
samples: [
|
||||||
rawLabel: 'Những gì được nghe thấy',
|
{
|
||||||
cleanLabel: 'Những gì được gõ ra',
|
raw: 'À thì... em nghĩ mình có thể, kiểu, để đợt phát hành vào thứ Sáu tuần sau chắc được',
|
||||||
sampleRaw: 'À thì... em nghĩ mình có thể, kiểu, để đợt phát hành vào thứ Sáu tuần sau chắc được',
|
clean: 'Tôi nghĩ chúng ta có thể lên lịch phát hành vào thứ Sáu tuần sau.',
|
||||||
sampleClean: 'Tôi nghĩ chúng ta có thể lên lịch phát hành vào thứ Sáu tuần sau.',
|
},
|
||||||
|
{
|
||||||
|
raw: 'bạn dọn lại ghi chú cuộc họp rồi đăng lên kênh nhóm trong hôm nay giúp mình à với cả file đính kèm nữa',
|
||||||
|
clean: 'Vui lòng dọn lại ghi chú cuộc họp và đăng lên kênh nhóm hôm nay, kèm theo các tệp đính kèm.',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: 'Từ ghi chú nhanh đến biên bản cuộc họp, tất cả ngay trên máy tính của bạn.',
|
title: 'Từ ghi chú nhanh đến biên bản cuộc họp, tất cả ngay trên máy tính của bạn.',
|
||||||
|
|
|
||||||
|
|
@ -27,23 +27,32 @@ export const zh: Translations = {
|
||||||
subtitle: '按住快捷键说话,松开即可。语音识别和文本润色都在你的电脑上完成,结果会直接输入到你正在使用的应用中。',
|
subtitle: '按住快捷键说话,松开即可。语音识别和文本润色都在你的电脑上完成,结果会直接输入到你正在使用的应用中。',
|
||||||
hotkeyKey: '右 Alt',
|
hotkeyKey: '右 Alt',
|
||||||
hotkeyAction: '按住说话',
|
hotkeyAction: '按住说话',
|
||||||
secondaryCta: '查看工作原理',
|
|
||||||
trust: '免费开始 · 无需账号 · 下载模型后可离线使用',
|
trust: '免费开始 · 无需账号 · 下载模型后可离线使用',
|
||||||
windowsOnly: '目前仅提供 Windows 版本。请在 Windows 电脑上打开此页面进行下载。',
|
windowsOnly: '目前仅提供 Windows 版本。请在 Windows 电脑上打开此页面进行下载。',
|
||||||
},
|
},
|
||||||
demo: {
|
demo: {
|
||||||
title: '示例',
|
label: '应用运行示例',
|
||||||
note: '这是一个脚本示例,不会录音。',
|
description: '按住右 Alt 说话时,屏幕底部会出现录音胶囊,实时显示你说的内容。松开后,句子会被润色并输入到备忘录窗口的光标位置。示例会循环播放。',
|
||||||
idle: '点击播放,查看语音转换为文字并被润色的过程。',
|
note: '这是脚本示例,并非真实录音。',
|
||||||
listening: '正在聆听',
|
windowTitle: '备忘录',
|
||||||
polishing: '正在润色',
|
docLine: '周五团队会议记录',
|
||||||
done: '已输入',
|
processing: '处理中...',
|
||||||
|
stateIdle: '等待中',
|
||||||
|
stateListening: '按住说话中',
|
||||||
|
statePolishing: '松开后润色中',
|
||||||
|
stateDone: '已输入到光标处',
|
||||||
|
pause: '暂停示例',
|
||||||
play: '播放示例',
|
play: '播放示例',
|
||||||
replay: '重新播放',
|
samples: [
|
||||||
rawLabel: '识别到的内容',
|
{
|
||||||
cleanLabel: '输入的内容',
|
raw: '呃就是说这次发布定在下周五左右应该可以',
|
||||||
sampleRaw: '呃就是说我们可以把发布定在下周五左右吧',
|
clean: '我们把这次发布定在下周五。',
|
||||||
sampleClean: '我们把发布定在下周五。',
|
},
|
||||||
|
{
|
||||||
|
raw: '把会议记录整理一下今天之内发到团队频道啊对了附件也一起',
|
||||||
|
clean: '请整理会议记录,并在今天之内连同附件一起发到团队频道。',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
features: {
|
features: {
|
||||||
title: '从随手记录到会议纪要,一切都在你的电脑上完成。',
|
title: '从随手记录到会议纪要,一切都在你的电脑上完成。',
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,173 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── 히어로 예시: 입력 대상 창 ─────────────────────────── */
|
||||||
|
.demo-window {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: #0f1116;
|
||||||
|
box-shadow: 0 24px 48px -12px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
.demo-titlebar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
|
||||||
|
background: #151821;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
.demo-app-icon {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
.demo-win-glyph {
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.demo-doc {
|
||||||
|
min-height: 236px;
|
||||||
|
padding: 22px 22px 108px;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
.demo-typed {
|
||||||
|
border-radius: 3px;
|
||||||
|
animation: demo-paste 1.6s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes demo-paste {
|
||||||
|
0% { background: rgba(59, 130, 246, 0.35); }
|
||||||
|
100% { background: transparent; }
|
||||||
|
}
|
||||||
|
.demo-caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1.15em;
|
||||||
|
margin-left: 1px;
|
||||||
|
vertical-align: text-bottom;
|
||||||
|
background: #f4f4f5;
|
||||||
|
animation: demo-caret 1.06s steps(1) infinite;
|
||||||
|
}
|
||||||
|
@keyframes demo-caret {
|
||||||
|
50% { opacity: 0; }
|
||||||
|
}
|
||||||
|
.demo-capsule-slot {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px) scale(0.98);
|
||||||
|
transition: opacity 180ms cubic-bezier(0.16, 1, 0.3, 1), transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.demo-capsule-slot.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 녹음 캡슐 — 앱 recording-tip/style.css 와 같은 값 ─── */
|
||||||
|
.capsule {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(15, 17, 22, 0.88);
|
||||||
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
backdrop-filter: blur(28px);
|
||||||
|
-webkit-backdrop-filter: blur(28px);
|
||||||
|
}
|
||||||
|
.capsule:has(.capsule-partial) {
|
||||||
|
border-radius: 18px;
|
||||||
|
}
|
||||||
|
.capsule-recording {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.capsule-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.capsule-bars {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.capsule-bar {
|
||||||
|
display: block;
|
||||||
|
width: 3px;
|
||||||
|
min-height: 3px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: height 80ms ease-out;
|
||||||
|
}
|
||||||
|
.capsule-bar:nth-child(1) { background: #93c5fd; opacity: 0.85; }
|
||||||
|
.capsule-bar:nth-child(2) { background: #60a5fa; opacity: 0.9; }
|
||||||
|
.capsule-bar:nth-child(3) { background: #4f8dfa; opacity: 0.95; }
|
||||||
|
.capsule-bar:nth-child(4) { background: #3b82f6; }
|
||||||
|
.capsule-bar:nth-child(5) { background: #3b82f6; }
|
||||||
|
.capsule-bar:nth-child(6) { background: #5b75f7; }
|
||||||
|
.capsule-bar:nth-child(7) { background: #6e71f7; opacity: 0.95; }
|
||||||
|
.capsule-bar:nth-child(8) { background: #818cf8; opacity: 0.9; }
|
||||||
|
.capsule-bar:nth-child(9) { background: #a78bfa; opacity: 0.85; }
|
||||||
|
.capsule-duration {
|
||||||
|
min-width: 34px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: #f4f4f5;
|
||||||
|
}
|
||||||
|
.capsule-partial {
|
||||||
|
display: -webkit-box;
|
||||||
|
max-height: 38px;
|
||||||
|
overflow: hidden;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
padding-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #f4f4f5;
|
||||||
|
}
|
||||||
|
.capsule-progress {
|
||||||
|
display: block;
|
||||||
|
width: 120px;
|
||||||
|
height: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
.capsule-progress-bar {
|
||||||
|
display: block;
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #3b82f6;
|
||||||
|
transition: width 100ms linear;
|
||||||
|
}
|
||||||
|
.capsule-thinking {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Grid Background Pattern (히어로) ─────────────────── */
|
/* ── Grid Background Pattern (히어로) ─────────────────── */
|
||||||
.bg-grid {
|
.bg-grid {
|
||||||
background-image:
|
background-image:
|
||||||
|
|
@ -82,63 +249,6 @@
|
||||||
background-size: 64px 64px;
|
background-size: 64px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CRT Screen (히어로 예시 화면의 계기판 재질) ─────── */
|
|
||||||
.crt-screen {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
background: linear-gradient(180deg, #05070a 0%, #0a0e14 100%);
|
|
||||||
border: 1px solid rgba(59, 130, 246, 0.25);
|
|
||||||
box-shadow:
|
|
||||||
inset 0 2px 10px rgba(0, 0, 0, 0.9),
|
|
||||||
0 0 32px rgba(59, 130, 246, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 정지된 주사선만 남긴다. 움직이는 스캔 효과는 읽기를 방해해 제거했다. */
|
|
||||||
.crt-screen::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: repeating-linear-gradient(
|
|
||||||
0deg,
|
|
||||||
transparent,
|
|
||||||
transparent 2px,
|
|
||||||
rgba(0, 0, 0, 0.18) 2px,
|
|
||||||
rgba(0, 0, 0, 0.18) 4px
|
|
||||||
);
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Crosshair Decorations ─────────────────────────────── */
|
|
||||||
.crosshair-box {
|
|
||||||
--ch-size: 16px;
|
|
||||||
--ch-color: rgba(59, 130, 246, 0.45);
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crosshair-box::before,
|
|
||||||
.crosshair-box::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
width: var(--ch-size);
|
|
||||||
height: var(--ch-size);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.crosshair-box::before {
|
|
||||||
top: -6px;
|
|
||||||
left: -6px;
|
|
||||||
border-top: 1.5px solid var(--ch-color);
|
|
||||||
border-left: 1.5px solid var(--ch-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.crosshair-box::after {
|
|
||||||
bottom: -6px;
|
|
||||||
right: -6px;
|
|
||||||
border-bottom: 1.5px solid var(--ch-color);
|
|
||||||
border-right: 1.5px solid var(--ch-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Utilities ─────────────────────────────────────────── */
|
/* ── Utilities ─────────────────────────────────────────── */
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
.text-balance {
|
.text-balance {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { Container } from '../components/Container'
|
import { Container } from '../components/Container'
|
||||||
import { DictationDemo } from '../components/DictationDemo'
|
import { AppLoopDemo } from '../components/AppLoopDemo'
|
||||||
import { DownloadIcon } from '../components/icons'
|
import { DownloadIcon } from '../components/icons'
|
||||||
import { useI18n } from '../i18n'
|
import { useI18n } from '../i18n'
|
||||||
import { useClientOS } from '../hooks/useClientOS'
|
import { useClientOS } from '../hooks/useClientOS'
|
||||||
|
|
@ -27,15 +27,12 @@ export function Hero() {
|
||||||
<span>{t.hero.hotkeyAction}</span>
|
<span>{t.hero.hotkeyAction}</span>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-9 flex flex-col gap-3 sm:flex-row sm:items-center">
|
<div className="mt-9 flex">
|
||||||
<a href={DESKTOP_WINDOWS_INSTALLER_URL} className="btn btn-primary btn-lg">
|
<a href={DESKTOP_WINDOWS_INSTALLER_URL} className="btn btn-primary btn-lg w-full sm:w-auto">
|
||||||
<DownloadIcon />
|
<DownloadIcon />
|
||||||
<span>{t.download.cta}</span>
|
<span>{t.download.cta}</span>
|
||||||
<span className="font-mono text-sm font-normal text-white">v{DESKTOP_VERSION}</span>
|
<span className="font-mono text-sm font-normal text-white">v{DESKTOP_VERSION}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="#how" className="btn btn-secondary btn-lg">
|
|
||||||
{t.hero.secondaryCta}
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{os !== 'windows' && (
|
{os !== 'windows' && (
|
||||||
|
|
@ -48,7 +45,7 @@ export function Hero() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="lg:col-span-5">
|
<div className="lg:col-span-5">
|
||||||
<DictationDemo />
|
<AppLoopDemo />
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
const siteRoot = dirname(fileURLToPath(import.meta.url))
|
const siteRoot = dirname(fileURLToPath(import.meta.url))
|
||||||
const safePublicFiles = [
|
const safePublicFiles = [
|
||||||
|
'404.html',
|
||||||
'.well-known/assetlinks.json',
|
'.well-known/assetlinks.json',
|
||||||
'accept-invite.css',
|
'accept-invite.css',
|
||||||
'accept-invite.html',
|
|
||||||
'accept-invite.js',
|
'accept-invite.js',
|
||||||
'accept-invite/index.html',
|
'accept-invite/index.html',
|
||||||
'download.html',
|
'download.html',
|
||||||
|
|
@ -20,8 +20,9 @@ const safePublicFiles = [
|
||||||
]
|
]
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// `public/releases` contains retained historical binaries. It is deliberately
|
// Installers ship only through the Forgejo feed, never from this site. Only this
|
||||||
// outside the build graph; only this non-binary allowlist can reach dist.
|
// non-binary allowlist can reach dist, so a stray local `public/releases` copy
|
||||||
|
// (git-ignored) can never be deployed.
|
||||||
publicDir: false,
|
publicDir: false,
|
||||||
plugins: [
|
plugins: [
|
||||||
react(),
|
react(),
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue