diff --git a/web-pages/product-site/README.md b/web-pages/product-site/README.md index 8fbda9992..692b2caa0 100644 --- a/web-pages/product-site/README.md +++ b/web-pages/product-site/README.md @@ -9,6 +9,11 @@ Python build; no browser framework or runtime application server is required. - Repository `docs/`, `model_zoo/`, `runtime/` and `examples/openai_api/` Markdown listed in the catalogue: documentation body, shared with GitHub readers. - `data/deployments.json`: versions, hardware, evidence and operational limits. +- `data/blog.json`: bilingual editorial titles, categories and the five-story + homepage selection. `blog.py` validates complete article coverage and renders + reading categories, archive and release history through `templates/blog.html`. + Published blog indexes are generated; the old `legacy/blog/index.html` and + English counterpart remain historical snapshots, not the live ordering source. - `templates/`: generated homepage, deployment manuals and documentation shell. - `assets/css/experience.css`: shared product and legacy reading experience. - `legacy/`: preserved public routes, content and demo assets. Do not delete or diff --git a/web-pages/product-site/assets/css/blog.css b/web-pages/product-site/assets/css/blog.css new file mode 100644 index 000000000..e9d04ae97 --- /dev/null +++ b/web-pages/product-site/assets/css/blog.css @@ -0,0 +1,34 @@ +.blog-publication{max-width:1120px;margin:0 auto;padding:52px 32px 64px;color:#192322;letter-spacing:0} +.blog-heading{max-width:820px;margin-bottom:28px} +.blog-heading h1{font-size:36px;line-height:1.2;font-weight:650;letter-spacing:0;margin:0 0 14px} +.blog-heading p{font-size:17px;line-height:1.65;color:#596562;margin:0} +.blog-parent{display:inline-block;font-size:14px;color:#14775e;margin-bottom:16px} +.blog-topics{display:flex;flex-wrap:wrap;gap:6px 28px;border-bottom:1px solid #dce4e0;margin-bottom:32px} +.blog-topics a{padding:10px 0 12px;color:#58635f;text-decoration:none;font-size:15px;border-bottom:2px solid transparent} +.blog-topics a[aria-current]{border-bottom-color:#13775e;color:#172a23;font-weight:650} +.blog-topics a:hover{color:#14775e} +.blog-publication a:focus-visible{outline:2px solid #13775e;outline-offset:5px} +.blog-lead{margin:0 0 40px} +.blog-publication .post-card{display:block;border:0;border-radius:0;box-shadow:none;background:transparent;padding:0;color:inherit;text-decoration:none;margin:0} +.blog-lead-story h2{font-size:28px;line-height:1.3;letter-spacing:0;font-weight:650;margin:9px 0 10px;max-width:900px} +.blog-kicker{font-size:13px;line-height:1.5;color:#14775e;font-weight:600} +.blog-lead-story p{max-width:850px;font-size:16px;line-height:1.65;color:#596562;margin:0 0 20px} +.blog-lead-story>img{display:block;width:100%;height:290px;object-fit:contain;object-position:center;background:#f5f7f6;border:1px solid #e5eae7} +.blog-caption{display:block;font-size:12px;color:#63706a;line-height:1.5;margin-top:8px} +.blog-section-heading{display:flex;align-items:baseline;justify-content:space-between;gap:20px;margin-bottom:8px} +.blog-section-heading h2{font-size:21px;font-weight:650;letter-spacing:0;margin:0} +.blog-section-heading a,.blog-release-link a{display:inline-flex;align-items:center;gap:8px;font-size:14px;color:#14775e} +.blog-section-heading img,.blog-release-link img,.blog-row-arrow{width:17px;height:17px;flex-shrink:0} +.blog-selection-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:44px} +.blog-publication .blog-story{border-top:1px solid #dce4e0;padding:24px 0;min-width:0} +.blog-story h3{font-size:21px;line-height:1.4;font-weight:600;letter-spacing:0;margin:8px 0} +.blog-story p{font-size:15px;line-height:1.75;color:#596562;margin:0;max-width:470px} +.blog-publication .post-card:hover h2,.blog-publication .post-card:hover h3{text-decoration:underline;text-decoration-thickness:1px;text-underline-offset:5px;color:#13775e} +.blog-publication .blog-archive-story{display:grid;grid-template-columns:145px minmax(0,1fr) 20px;align-items:center;gap:22px;border-top:1px solid #dce4e0;padding:22px 0} +.blog-archive-meta{font-size:13px;color:#63706a;display:flex;flex-direction:column;gap:5px} +.blog-archive-story h2{font-size:20px;line-height:1.5;font-weight:550;letter-spacing:0;margin:0} +.blog-archive-story p{font-size:14px;line-height:1.7;color:#596562;margin:8px 0 0} +.blog-more{display:flex;gap:28px;flex-wrap:wrap;border-top:1px solid #dce4e0;margin-top:28px;padding-top:24px;font-size:14px} +.blog-more a{color:#53605a}.blog-more a[aria-current]{color:#14775e;font-weight:650} +.blog-publication h1,.blog-publication h2,.blog-publication h3,.blog-publication p{overflow-wrap:break-word} +@media(max-width:700px){.blog-publication{padding:32px 20px 48px}.blog-heading h1{font-size:30px}.blog-heading p{font-size:15px}.blog-topics{gap:4px 19px;margin-bottom:26px}.blog-topics a{font-size:14px}.blog-lead-story h2{font-size:25px}.blog-lead-story p{font-size:15px}.blog-lead-story>img{height:210px}.blog-lead{margin-bottom:34px}.blog-selection-grid{grid-template-columns:1fr}.blog-story h3{font-size:21px}.blog-section-heading h2{font-size:20px}.blog-section-heading{gap:14px}.blog-publication .blog-archive-story{grid-template-columns:minmax(0,1fr) 17px;gap:10px 12px;padding:20px 0}.blog-archive-meta{grid-column:1 / -1;flex-direction:row;gap:15px}.blog-archive-story h2{font-size:19px}.blog-caption{font-size:11px}} diff --git a/web-pages/product-site/blog.py b/web-pages/product-site/blog.py new file mode 100644 index 000000000..a81fdc722 --- /dev/null +++ b/web-pages/product-site/blog.py @@ -0,0 +1,107 @@ +"""Curated blog navigation; article bodies remain in the legacy corpus.""" + +import json +import re +from pathlib import Path + +from bs4 import BeautifulSoup + +SITE_ROOT = Path(__file__).resolve().parent +CATEGORIES = { + "applications": {"zh": "应用实践", "en": "Applications"}, + "selection": {"zh": "选型指南", "en": "Choosing a solution"}, + "explanations": {"zh": "技术解读", "en": "Technical explanations"}, + "releases": {"zh": "版本记录", "en": "Release history"}, +} + + +def validate_blog(data, root=SITE_ROOT): + """Fail the build if editorial navigation loses an existing article.""" + if data.get("schema_version") != 1 or not isinstance(data.get("articles"), list): + raise ValueError("invalid blog catalogue") + seen = set() + for entry in data["articles"]: + slug = entry.get("slug", "") + if not isinstance(slug, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", slug) or slug in seen: + raise ValueError("invalid or duplicate blog slug") + seen.add(slug) + if entry.get("category") not in CATEGORIES or type(entry.get("reviewed")) is not bool: + raise ValueError("invalid blog category or review state") + for language, title_limit, summary_limit in (("zh", 44, 110), ("en", 100, 210)): + text = entry.get(language) + if not isinstance(text, dict) or not isinstance(text.get("title"), str) or not 1 <= len(text["title"].strip()) <= title_limit: + raise ValueError("missing or overlong blog title") + summary = text.get("summary", "") + if not isinstance(summary, str) or len(summary) > summary_limit or (entry["reviewed"] and not summary.strip()): + raise ValueError("missing or overlong blog summary") + for prefix in ("", "en"): + actual = {p.stem for p in (root / "legacy" / prefix / "blog").glob("*.html") if p.name != "index.html"} + if seen != actual: + raise ValueError("blog catalogue must cover the complete bilingual article corpus") + selected = [data.get("lead"), *data.get("selected", [])] + if len(selected) != 5 or len(set(selected)) != 5 or not set(selected) <= seen: + raise ValueError("blog home requires one lead and four distinct selected stories") + by_slug = {entry["slug"]: entry for entry in data["articles"]} + if any(not by_slug[slug]["reviewed"] or by_slug[slug]["category"] == "releases" for slug in selected): + raise ValueError("only reviewed reader stories belong on the homepage") + image = data.get("lead_image", "") + if not isinstance(image, str) or not image.startswith("/img/") or ".." in Path(image).parts or not (root / "legacy" / image.lstrip("/")).is_file(): + raise ValueError("blog lead requires an existing local image") + return data + + +def load_blog(root=SITE_ROOT): + return validate_blog(json.loads((root / "data/blog.json").read_text(encoding="utf-8")), root) + + +def _published(root, slug, language): + prefix = "en" if language == "en" else "" + soup = BeautifulSoup((root / "legacy" / prefix / "blog" / f"{slug}.html").read_text(encoding="utf-8"), "html.parser") + for node in soup.select('script[type="application/ld+json"]'): + value = json.loads(node.get_text()) + if isinstance(value, dict) and isinstance(value.get("datePublished"), str): + date = value["datePublished"][:10] + if re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): + return date + return "" + + +def blog_views(data, language, root=SITE_ROOT): + prefix = "/en" if language == "en" else "" + peer = "" if language == "en" else "/en" + rows = { + entry["slug"]: { + **entry[language], "slug": entry["slug"], "category": entry["category"], + "category_label": CATEGORIES[entry["category"]][language], "reviewed": entry["reviewed"], + "href": f"{prefix}/blog/{entry['slug']}.html", "date": _published(root, entry["slug"], language), + } + for entry in data["articles"] + } + ordered = sorted(rows.values(), key=lambda row: (row["date"], row["slug"]), reverse=True) + headings = { + "home": {"zh": "FunASR 技术博客", "en": "FunASR Blog"}, + "archive": {"zh": "全部文章", "en": "All articles"}, + **CATEGORIES, + } + descriptions = { + "home": {"zh": "从一个真实问题出发,把语音技术用起来。", "en": "Start with a real problem. Put speech technology to work."}, + "applications": {"zh": "从录音、字幕到自己的服务,走完一条应用路径。", "en": "From recordings and subtitles to your own service, one workflow at a time."}, + "selection": {"zh": "根据应用需求选方案,先弄清楚哪些能力真正匹配。", "en": "Choose a path around the capabilities your application actually needs."}, + "explanations": {"zh": "解释一个关键问题,帮助你做出更好的工程判断。", "en": "Understand one important question and make a better engineering decision."}, + "archive": {"zh": "保留全部历史文章。旧文中的版本与测量对应当时环境,当前配置请以维护中的文档为准。", "en": "The complete archive. Versions and measurements in older articles reflect their original environments; use maintained documentation for current setup."}, + "releases": {"zh": "按发布时间回看版本变化;当前安装包与完整记录见 GitHub Releases。", "en": "Changes at the time of each release. Find current packages and complete release notes on GitHub."}, + } + for view in ("home", "applications", "selection", "explanations", "archive", "releases"): + suffix = "" if view == "home" else view + "/" + selected = [rows[slug] for slug in [data["lead"], *data["selected"]]] + stories = selected if view == "home" else [row for row in ordered if view == "archive" or + (row["category"] == view and (row["reviewed"] or view == "releases"))] + yield { + "view": view, "heading": headings[view][language], "description": descriptions[view][language], + "route": f"{prefix}/blog/{suffix}", "peer_route": f"{peer}/blog/{suffix}", + "stories": stories, "lead": selected[0], "selected": selected[1:], "lead_image": data["lead_image"], + "prefix": prefix, + "topics": [{"id": name, "label": ("精选" if language == "zh" else "Selected") if name == "home" else CATEGORIES[name][language], + "href": f"{prefix}/blog/" + ("" if name == "home" else name + "/")} + for name in ("home", "applications", "selection", "explanations")], + } diff --git a/web-pages/product-site/build.py b/web-pages/product-site/build.py index 7c8ccc1ac..769fbf128 100644 --- a/web-pages/product-site/build.py +++ b/web-pages/product-site/build.py @@ -17,6 +17,7 @@ from legacy import normalize_document from registry import load_registry, validate_registry from selector import MATCH_WEIGHTS +from blog import load_blog, blog_views from documentation import load_catalogue, doc_route, render_source, write_search_indexes @@ -432,6 +433,19 @@ def build(output_dir: Path) -> dict[str, Any]: table.wrap(wrapper) path.write_text(str(soup), encoding='utf-8') + blog_data = load_blog(SITE_ROOT) + for language in ('zh', 'en'): + for view in blog_views(blog_data, language, SITE_ROOT): + context = _page_context( + language=language, route=view['route'], peer_route=view['peer_route'], + title=view['heading'] + ' - FunASR', description=view['description'], + date_modified=registry['verified'], navigation=navigation, assets=assets, + ) + context['blog'] = view + _render_page(environment, 'blog.html', route_path(stage, view['route']), context) + pages.append({'route': view['route'], 'language': language, + 'canonical': context['canonical'], 'hreflang': context['peer_canonical']}) + write_search_indexes(stage) last_modified_by_route = { entry['routes'][language]: entry['tested']['verified'] diff --git a/web-pages/product-site/content/legacy-manifest.json b/web-pages/product-site/content/legacy-manifest.json index 2653b16c9..a4433029e 100644 --- a/web-pages/product-site/content/legacy-manifest.json +++ b/web-pages/product-site/content/legacy-manifest.json @@ -3,8 +3,8 @@ "source": "https://www.funasr.com/ public static corpus", "captured": "2026-07-26", "files": { - "blog/fun-asr-nano-transformers.html": "e9834f5d62f3d172abcbb9d9157e19bc0fed8ff0f68bc4e7030dd7b1e65fb0ff", - "en/blog/fun-asr-nano-transformers.html": "67fe99f396c537915828978c7b2a755360ed8bcb24b3eb082a743f41744e102f", + "blog/fun-asr-nano-transformers.html": "dd8e53b59e502fd6ff54237943564fbc05886eae747275b97dc862281cf80bbe", + "en/blog/fun-asr-nano-transformers.html": "9619a7fae30c444a67e566fe6bbe44fea1805f2778e5e8003f0387aacb33e29e", "img/fun-asr-nano-native-waveform.png": "930517464ffb47bc7f9051dbdcb5422e1b3daa5c6abaa455a8bf072359ae0f0b", "7ec404429c825fe9a9a030731bbca986.txt": "e0e4c945481cb03f52426d8420c3407ea18cd3b46cb2482590bd50e5aab117d2", "blog/cantonese-speech-recognition.html": "f4a674f2b8f15963a386d586bb79d62f9f75c20d704ca2eafba21a800f52d4df", @@ -14,7 +14,7 @@ "blog/funasr-llama-cpp-whisper-cpp-alternative.html": "3e1a8901c3f7f74e310b8050e2f6722c736ce0b37155805b85786d5b5455ae8f", "blog/funasr-realtime-streaming-asr.html": "7b6c34ace6c4c782f5af9c3f52181c1b1ef08d32c4ea3fafab63b1f30a9e72db", "blog/funasr-speaker-diarization.html": "451273993769f38de014160788581529788db8e4ca9fb56773d7723c51d96a52", - "blog/funasr-transcribe-long-audio.html": "3cb00a3b23eeb5990b639fde779ef738a58a08af8976f86a52860ac052537160", + "blog/funasr-transcribe-long-audio.html": "9826552420271d42a86b90f3180411d0293a0c67916cd38d47cfa93bbf2b0e32", "blog/funasr-v1-3-26-openai-vllm-llama-cpp.html": "304453b66c09d65aaa35dfe679c7368c6c69d65ab68bae7d85c536aadecf44b1", "blog/funasr-v1-3-27-language-metadata-vllm-fallback.html": "f1dbbc70fdd630c8a3953bda3b3a1697412b3c4bc25fc3969385583cd47874d2", "blog/funasr-v1-3-28-realtime-websocket-subtitles.html": "46a8d4c310e7f76da632d558d057484f48b345d9616873e1416fc68fcb9e9633", @@ -25,16 +25,16 @@ "blog/funasr-vs-faster-whisper-chinese.html": "bfe9bb8017be80c7e7f4587726f43f6064f1dc4c65e39788f836fdfb0c9789f7", "blog/funasr-vs-whisper-benchmark.html": "b7b49adf24d20570abb09b733ce03d4a50a4a0e98e746b4a9320f453e01cce84", "blog/funclip-v2-1-0-video-clipping-release.html": "88f6c44e5332d1746c4db0fc97d755ef82f12e46d8f1c1c0ad9351152ff9dfc1", - "blog/funclip-v2-2-0-moss-speaker-clipping.html": "0d50747d3992301fb3062ad2cdf903bd427bec3db1f1e4fab1f06de207928bc9", + "blog/funclip-v2-2-0-moss-speaker-clipping.html": "db4ee351aefc1cb6997ed36b624cbc7e2527d81665635bd6169550d782a2e145", "blog/generate-subtitles-srt-vtt-from-audio-video.html": "f1133235673d441cc654310581f59a319f182314ab7c0b0824af87da9f4a0591", "blog/index.html": "bd04951ed11b340a6786c7b38a25beace6554f7269274c2d25d4228ebaaf2a0e", "blog/japanese-speech-recognition.html": "399f5ce84e68ac00854bdf70b52b1cde6795efca7c800fd6492035a37a7c1b68", "blog/lightweight-speech-recognition-cpu.html": "d6270066222ed5baef0df108e6a4155f4a87bb5482169f23f218794d371f3281", - "blog/meeting-transcript-acceptance.html": "3e35d1f283c7005977ad8cbd079e38c51e14b51f04f50cedb5636e2d4baf38bd", + "blog/meeting-transcript-acceptance.html": "8173b3049ca707bdac46013748bc0ca7d9576ebf6f7ff6db6c940a833b12e4a9", "blog/punctuation-restoration-python.html": "6cd26e03bf75b4343afd7b351c2681ebd513b058fb549c5888802be9ff3a1229", "blog/self-hosted-deepgram-assemblyai-alternative.html": "feb17aae8e0f3fe46914c8725eacc8fcdf7fe78bf22a291347d6107c24e3992e", "blog/self-hosted-google-aws-azure-speech-to-text.html": "8e4000fb9e2b33a48a3907d4bd0f55db57ba74121a89b5fa12b412c3c963f49a", - "blog/self-hosted-openai-whisper-api-alternative.html": "3c44496e02a15addfe6063b9b8c2d586b3861925701c4fbd81dba77be69c5de9", + "blog/self-hosted-openai-whisper-api-alternative.html": "254184cd753f30e308329c301ae47e8f5f5dfb57c01d0abf3ce341034f2990f9", "blog/sensevoice-deployment-guide.html": "61c34fb7626444a0f1b5c93013dd95c10c576333791eaca1ab819baf0a8cdad6", "blog/sensevoice-emotion-language-detection.html": "a00c40bfbaa3f0f390bc00f052b7e4e299a7d193b088014b7515813f1f8a636b", "blog/speech-to-text-python-transcribe-audio.html": "87335710235dfdec9e01288f03a9a294c7bc3f730110e8d06f2b4c9d4906e284", @@ -56,7 +56,7 @@ "en/blog/funasr-llama-cpp-whisper-cpp-alternative.html": "3808ee7a014f63e3befd83c886eca3fd96abea7d4de68a90a8bd23befe579282", "en/blog/funasr-realtime-streaming-asr.html": "65c7353fcdc393fc0adec3969d9762edc8f30dc15d20e360a2b11508d8c60a91", "en/blog/funasr-speaker-diarization.html": "db6511de2f356b7bf5e3f1f89b33991500a293c9d3bd2420d8a6cd6cf6cf3997", - "en/blog/funasr-transcribe-long-audio.html": "cb9b03a87487b3c551db030f66c16b8a55c042977d5d96cf6012af4cb4a1e273", + "en/blog/funasr-transcribe-long-audio.html": "d4dafb52210440f91150b513b06f7a55c085f14bb88aefea1b51d5cdbced9aa6", "en/blog/funasr-v1-3-26-openai-vllm-llama-cpp.html": "665c9c66c9af0e649cc3020859e3af65f6ec595dfdbf36923058b97dc5e2512e", "en/blog/funasr-v1-3-27-language-metadata-vllm-fallback.html": "ffaca54d75c561bda82b6063b27f2a81d810f4f0e89030b6be7a64696ada44b4", "en/blog/funasr-v1-3-28-realtime-websocket-subtitles.html": "233534cb4306387f1a9a695886a5f515679dff591d04b90827f41a668d7f7ada", @@ -67,16 +67,16 @@ "en/blog/funasr-vs-faster-whisper-chinese.html": "abf31d3827dfba9b31ccd4e76229de9e4bfff00ab8bcee33bd718c88249630f4", "en/blog/funasr-vs-whisper-benchmark.html": "367d4a8a1cc09ac932c80cdad065127925c5e5f942a806ace683c16dc1132769", "en/blog/funclip-v2-1-0-video-clipping-release.html": "229baf59adf2c3290541d9b3c8a6243992406ba84b712714e9d14599205cb1d4", - "en/blog/funclip-v2-2-0-moss-speaker-clipping.html": "644159267f0fa4e7aab9d0eda98f68cd2b6365a9985038b639c626a5ea5ea7e8", + "en/blog/funclip-v2-2-0-moss-speaker-clipping.html": "115f7dd0963754ed75c6022bb8ceb8ddf83b0391b1ba215af32358b2568c240a", "en/blog/generate-subtitles-srt-vtt-from-audio-video.html": "1530d4b9e94820a0b60d801e8d1c19b7aeb031b2f2b76c092657392e47706c6d", "en/blog/index.html": "fe18072fb67da6b9cc274fa26b8dabc5854c426a9a38210bb2317219e8123900", "en/blog/japanese-speech-recognition.html": "c476adcc2be1ed7c19e2345cc91b8ee6a0e04efd794b9b6476dd5d9b3a2c8b04", "en/blog/lightweight-speech-recognition-cpu.html": "0e02c0e0853b12613d32c250f593c05c2052f18231207b63ee01d6e6600a86f7", - "en/blog/meeting-transcript-acceptance.html": "3183682d2cae0ce18f68e3bd33e22b9effb4de699f6a4d821938cc03dd6835bf", + "en/blog/meeting-transcript-acceptance.html": "ffc6eb2329267515bb367f21bb2d1c0523ff90d3f02b2e734ae41f78b02d0515", "en/blog/punctuation-restoration-python.html": "d6e30049f48d9dc6e6bc22eb567830013e4161eca51215616738b675dd1ffc40", "en/blog/self-hosted-deepgram-assemblyai-alternative.html": "4239a93246726c6f010a2fb20208211e050792a372e3ea356d1a93ac028a8a96", "en/blog/self-hosted-google-aws-azure-speech-to-text.html": "4a8453451722b3ec8735224a586744ddfbfec9458cedd433f9fc908351027e9a", - "en/blog/self-hosted-openai-whisper-api-alternative.html": "3e004bdf307bfb25fe2261554f130077678ca99b58f9dfe0e48b1ecae5547c19", + "en/blog/self-hosted-openai-whisper-api-alternative.html": "14bdb877adfd113eeb04b215ffa015c29d8323859eeddd00d9e03d166dfa0473", "en/blog/sensevoice-deployment-guide.html": "8882912c6a84ec0a0e025d0eff8fcfa74bf76324bd4c9cae862c43c87c6e10b3", "en/blog/sensevoice-emotion-language-detection.html": "4fa0d49d34df3b923323fc49fbb02c432efd7cd0d90d81e84621dbbecc08cd29", "en/blog/speech-to-text-python-transcribe-audio.html": "4fc57aef3c6177e28afa26fa6c41f67280b986e384b32a63a2f79ca67bcfe631", diff --git a/web-pages/product-site/data/blog.json b/web-pages/product-site/data/blog.json new file mode 100644 index 000000000..cd29becd0 --- /dev/null +++ b/web-pages/product-site/data/blog.json @@ -0,0 +1,468 @@ +{ + "schema_version": 1, + "lead": "funclip-v2-2-0-moss-speaker-clipping", + "selected": [ + "meeting-transcript-acceptance", + "fun-asr-nano-transformers", + "self-hosted-openai-whisper-api-alternative", + "funasr-transcribe-long-audio" + ], + "lead_image": "/img/funclip-v2-1-0-interface.jpg", + "articles": [ + { + "slug": "cantonese-speech-recognition", + "category": "applications", + "reviewed": false, + "zh": { + "title": "粤语录音转写实践", + "summary": "" + }, + "en": { + "title": "Transcribing Cantonese recordings", + "summary": "" + } + }, + { + "slug": "chinese-speech-recognition", + "category": "applications", + "reviewed": false, + "zh": { + "title": "中文录音转写入门", + "summary": "" + }, + "en": { + "title": "Getting started with Mandarin transcription", + "summary": "" + } + }, + { + "slug": "fun-asr-nano-guide", + "category": "selection", + "reviewed": false, + "zh": { + "title": "Fun-ASR-Nano 使用笔记", + "summary": "" + }, + "en": { + "title": "Using Fun-ASR-Nano", + "summary": "" + } + }, + { + "slug": "fun-asr-nano-transformers", + "category": "selection", + "reviewed": true, + "zh": { + "title": "接入 Transformers,先选对权重", + "summary": "同名模型的权重格式不能混用。先理清格式,再跑通原生接入。" + }, + "en": { + "title": "Choose the right checkpoint for Transformers", + "summary": "Checkpoint formats are not interchangeable. Choose the right artifact before connecting the API." + } + }, + { + "slug": "funasr-cli-transcribe-command-line", + "category": "applications", + "reviewed": false, + "zh": { + "title": "从命令行转写音频", + "summary": "" + }, + "en": { + "title": "Transcribing audio from the command line", + "summary": "" + } + }, + { + "slug": "funasr-llama-cpp-whisper-cpp-alternative", + "category": "selection", + "reviewed": false, + "zh": { + "title": "用 llama.cpp 部署中文语音识别", + "summary": "" + }, + "en": { + "title": "Chinese speech recognition with llama.cpp", + "summary": "" + } + }, + { + "slug": "funasr-realtime-streaming-asr", + "category": "applications", + "reviewed": false, + "zh": { + "title": "实时字幕的流式识别路径", + "summary": "" + }, + "en": { + "title": "A streaming path for live captions", + "summary": "" + } + }, + { + "slug": "funasr-speaker-diarization", + "category": "explanations", + "reviewed": false, + "zh": { + "title": "说话人分离:谁在何时说话", + "summary": "" + }, + "en": { + "title": "Speaker diarization: who spoke when", + "summary": "" + } + }, + { + "slug": "funasr-transcribe-long-audio", + "category": "explanations", + "reviewed": true, + "zh": { + "title": "长录音为什么会漏掉结尾?", + "summary": "从切分边界、生成上限和结果覆盖出发,检查长录音的遗漏。" + }, + "en": { + "title": "Why can a long transcript miss the ending?", + "summary": "Check segmentation, generation limits and output coverage when a recording seems incomplete." + } + }, + { + "slug": "funasr-v1-3-26-openai-vllm-llama-cpp", + "category": "releases", + "reviewed": false, + "zh": { + "title": "v1.3.26:API 与运行时入口", + "summary": "" + }, + "en": { + "title": "v1.3.26: API and runtime entry points", + "summary": "" + } + }, + { + "slug": "funasr-v1-3-27-language-metadata-vllm-fallback", + "category": "releases", + "reviewed": false, + "zh": { + "title": "v1.3.27:语种信息与回退处理", + "summary": "" + }, + "en": { + "title": "v1.3.27: Language metadata and fallback", + "summary": "" + } + }, + { + "slug": "funasr-v1-3-28-realtime-websocket-subtitles", + "category": "releases", + "reviewed": false, + "zh": { + "title": "v1.3.28:实时识别与字幕修复", + "summary": "" + }, + "en": { + "title": "v1.3.28: Realtime and subtitle fixes", + "summary": "" + } + }, + { + "slug": "funasr-v1-4-0-pypi-release", + "category": "releases", + "reviewed": false, + "zh": { + "title": "v1.4.0:安装包与参数校验", + "summary": "" + }, + "en": { + "title": "v1.4.0: Packaging and argument validation", + "summary": "" + } + }, + { + "slug": "funasr-v1-4-14-portable-source-release", + "category": "releases", + "reviewed": false, + "zh": { + "title": "v1.4.14:源码包与 MOSS 入口", + "summary": "" + }, + "en": { + "title": "v1.4.14: Source packages and MOSS discovery", + "summary": "" + } + }, + { + "slug": "funasr-v1-4-3-pypi-release", + "category": "releases", + "reviewed": false, + "zh": { + "title": "v1.4.3:VAD 与说话人聚类", + "summary": "" + }, + "en": { + "title": "v1.4.3: VAD and speaker clustering", + "summary": "" + } + }, + { + "slug": "funasr-v1-4-5-pypi-llama-cpp-release", + "category": "releases", + "reviewed": false, + "zh": { + "title": "v1.4.5:Python 依赖与运行时", + "summary": "" + }, + "en": { + "title": "v1.4.5: Python dependencies and runtimes", + "summary": "" + } + }, + { + "slug": "funasr-vs-faster-whisper-chinese", + "category": "selection", + "reviewed": false, + "zh": { + "title": "中文与粤语:历史对比实验", + "summary": "" + }, + "en": { + "title": "Chinese and Cantonese: an earlier comparison", + "summary": "" + } + }, + { + "slug": "funasr-vs-whisper-benchmark", + "category": "selection", + "reviewed": false, + "zh": { + "title": "FunASR 与 Whisper:历史测量记录", + "summary": "" + }, + "en": { + "title": "FunASR and Whisper: historical measurements", + "summary": "" + } + }, + { + "slug": "funclip-v2-1-0-video-clipping-release", + "category": "releases", + "reviewed": false, + "zh": { + "title": "FunClip v2.1.0:首个版本化发布", + "summary": "" + }, + "en": { + "title": "FunClip v2.1.0: The first versioned release", + "summary": "" + } + }, + { + "slug": "funclip-v2-2-0-moss-speaker-clipping", + "category": "applications", + "reviewed": true, + "zh": { + "title": "把多人录音变成可剪辑的字幕", + "summary": "从一段对话出发,生成带匿名说话人标签的字幕,再按人选段。" + }, + "en": { + "title": "Turn a conversation into editable subtitles", + "summary": "Create subtitles with recording-local speaker labels, then select the passages you need." + } + }, + { + "slug": "generate-subtitles-srt-vtt-from-audio-video", + "category": "applications", + "reviewed": false, + "zh": { + "title": "从录音生成字幕文件", + "summary": "" + }, + "en": { + "title": "From a recording to subtitle files", + "summary": "" + } + }, + { + "slug": "japanese-speech-recognition", + "category": "applications", + "reviewed": false, + "zh": { + "title": "日语录音转写实践", + "summary": "" + }, + "en": { + "title": "Transcribing Japanese recordings", + "summary": "" + } + }, + { + "slug": "lightweight-speech-recognition-cpu", + "category": "selection", + "reviewed": false, + "zh": { + "title": "CPU 语音识别的部署取舍", + "summary": "" + }, + "en": { + "title": "Deployment choices for CPU speech recognition", + "summary": "" + } + }, + { + "slug": "meeting-transcript-acceptance", + "category": "applications", + "reviewed": true, + "zh": { + "title": "会议转写,怎样才算做好了?", + "summary": "有文字还不够:检查关键内容、说话人轮次和时间覆盖。" + }, + "en": { + "title": "When is a meeting transcript ready to use?", + "summary": "Text alone is not enough. Review key details, speaker turns and time coverage." + } + }, + { + "slug": "punctuation-restoration-python", + "category": "explanations", + "reviewed": false, + "zh": { + "title": "怎样给转写结果补标点", + "summary": "" + }, + "en": { + "title": "Restoring punctuation in a transcript", + "summary": "" + } + }, + { + "slug": "self-hosted-deepgram-assemblyai-alternative", + "category": "selection", + "reviewed": false, + "zh": { + "title": "迁移语音 API 前,先核对什么", + "summary": "" + }, + "en": { + "title": "What to check before migrating a speech API", + "summary": "" + } + }, + { + "slug": "self-hosted-google-aws-azure-speech-to-text", + "category": "selection", + "reviewed": false, + "zh": { + "title": "自托管语音服务的迁移笔记", + "summary": "" + }, + "en": { + "title": "Notes on migrating to self-hosted speech", + "summary": "" + } + }, + { + "slug": "self-hosted-openai-whisper-api-alternative", + "category": "applications", + "reviewed": true, + "zh": { + "title": "把语音转写接进自己的 API", + "summary": "先跑通本地请求,再明确鉴权、返回格式和服务责任。" + }, + "en": { + "title": "Add transcription to your own API", + "summary": "Start with a local request, then define authentication, output formats and operating responsibilities." + } + }, + { + "slug": "sensevoice-deployment-guide", + "category": "applications", + "reviewed": false, + "zh": { + "title": "SenseVoice 部署入门", + "summary": "" + }, + "en": { + "title": "Getting started with SenseVoice", + "summary": "" + } + }, + { + "slug": "sensevoice-emotion-language-detection", + "category": "explanations", + "reviewed": false, + "zh": { + "title": "文字之外,语音里还有什么信息", + "summary": "" + }, + "en": { + "title": "What can speech tell us beyond the words?", + "summary": "" + } + }, + { + "slug": "speech-to-text-python-transcribe-audio", + "category": "applications", + "reviewed": false, + "zh": { + "title": "用 Python 转写一份录音", + "summary": "" + }, + "en": { + "title": "Transcribe a recording with Python", + "summary": "" + } + }, + { + "slug": "speech-to-text-timestamps-python", + "category": "applications", + "reviewed": false, + "zh": { + "title": "用时间戳定位原始录音", + "summary": "" + }, + "en": { + "title": "Finding the original audio with timestamps", + "summary": "" + } + }, + { + "slug": "subtitle-edit-fun-asr-sensevoice-local-subtitles", + "category": "applications", + "reviewed": false, + "zh": { + "title": "在 Subtitle Edit 中生成本地字幕", + "summary": "" + }, + "en": { + "title": "Generating local subtitles in Subtitle Edit", + "summary": "" + } + }, + { + "slug": "voice-activity-detection-python", + "category": "explanations", + "reviewed": false, + "zh": { + "title": "VAD 如何找到语音区间", + "summary": "" + }, + "en": { + "title": "How VAD identifies speech regions", + "summary": "" + } + }, + { + "slug": "which-funasr-model", + "category": "selection", + "reviewed": false, + "zh": { + "title": "FunASR 模型选型笔记", + "summary": "" + }, + "en": { + "title": "Notes on choosing a FunASR model", + "summary": "" + } + } + ] +} diff --git a/web-pages/product-site/legacy/blog/fun-asr-nano-transformers.html b/web-pages/product-site/legacy/blog/fun-asr-nano-transformers.html index 5d5ae14f1..e426073ed 100644 --- a/web-pages/product-site/legacy/blog/fun-asr-nano-transformers.html +++ b/web-pages/product-site/legacy/blog/fun-asr-nano-transformers.html @@ -1,41 +1,34 @@ -Fun-ASR-Nano + Transformers:先选对权重,再接入应用 | FunASR - - +接入 Transformers,先选对权重 | FunASR + + - - + +
-

Fun-ASR-Nano + Transformers:先选对权重,再接入应用

+

接入 Transformers,先选对权重

2026-09-09 · 生态技术与应用 · 阅读约 7 分钟

-

在已有 Hugging Face 应用里接入语音识别,最容易踩的坑往往不是模型大小,而是把“同一模型家族”当成“同一种权重和接口”。Fun-ASR-Nano 已进入 Transformers 主线。这篇文章从 checkpoint、处理器到生成结果,解释如何把这次集成接到自己的应用中。

-

先确认安装包,不只看合并状态

-

PR #46180 于 2026-09-09 合并。当天核验的稳定版 5.16.1 还没有原生 fun_asr_nano 文件;源码包显示的 5.17.0.dev0 也不是下一稳定版发布承诺。请从固定版本接入指南安装明确的源码提交,不要只执行一次无版本约束的升级就假定已经兼容。

-

这种区别影响排障方向:未知模型类型可能是安装包未包含实现,不一定是 checkpoint 损坏;模型能下载也不代表所选运行时认识它。

+

如果你已经在用 Transformers,接入语音识别时先确认权重属于这条接口。同一个模型名字,不代表工具包、服务端和原生 Python 能互换文件。

+

例如,文件下载完了,应用却提示“不认识模型类型”。先检查安装包有没有实现、权重是不是对应格式,不要急着把问题归为文件损坏。本篇选择官方 -hf 权重。

+

这条路径生成转写文本,不是现成服务。加载成功不等于拿到了字级时间戳、说话人分离或实时流式能力。

+ +
用于功能检查的官方中文样本波形,横轴为秒,纵轴为振幅
官方中文样本原始波形:约 5.62 秒、单声道 48 kHz。运行指南前应明确重采样为 16 kHz。波形图不是准确率或性能结果。

同一模型家族,四条不同路径

-
- - - - -
应用已有的接口选择什么不要混淆
FunASR AutoModelFun-ASR-Nano-2512原始工具包路径;拆分引擎看对应文档。
Transformers processor / generateFun-ASR-Nano-2512-hf本篇的原生接口,不是 vLLM 的转换包。
原生 vLLM HTTP 服务Fun-ASR-Nano-2512-vllm使用独立运行时、模型格式及服务参数。
C++ / GGML 运行时与运行时匹配的 GGUF不是把 Transformers 目录改后缀。
+
    +
  • FunASR AutoModel:Fun-ASR-Nano-2512。原始工具包路径;拆分引擎看对应文档。
  • +
  • Transformers processor / generate:Fun-ASR-Nano-2512-hf。本篇的原生接口,不是 vLLM 的转换包。
  • +
  • 原生 vLLM HTTP 服务:Fun-ASR-Nano-2512-vllm。使用独立运行时、模型格式及服务参数。
  • +
  • C++ / GGML 运行时:与运行时匹配的 GGUF。不是把 Transformers 目录改后缀。
  • +

这几个官方模型仓库位于 FunAudioLLM 下。原生 Transformers 路径加载 官方 -hf checkpoint,模型和处理器代码来自固定的 Transformers 安装,不需要执行 checkpoint 的远程 Python 代码。它减少了一种接入依赖,并不自动提供鉴权、队列或服务监控。

如果目标是多客户端并发请求,直接查看原生 vLLM 部署页;如果目标是离线 C++ 运行,查看llama.cpp 部署页。原生 Python 接口是一个可组合入口,不是所有服务的替换品。

-

从声音到文本,中间实际经过什么?

-
  1. 音频样本。采样率与声道是数据含义的一部分。48 kHz 的数组不能只改参数标成 16 kHz;那会改变模型看到的时间尺度。指南先要求明确的单声道 16 kHz WAV。
  2. -
  3. 音频特征。原生特征提取器用 torchaudio 的 Kaldi fbank,再做 LFR 堆叠与降采样。这里仍需要匹配版本的 torchaudio;不能套用 FunASR 工具包的可选依赖结论。
  4. -
  5. 转写请求。apply_transcription_request 把音频、语言、上下文和关键词整理成模型的聊天模板,并对齐音频占位 token 与特征。
  6. -
  7. 生成和解码。AutoModelForSpeechSeq2Seq 返回 token。去掉输入 prompt 的长度后再解码,才能避免把模板混进转写文本。
-
用于功能检查的官方中文样本波形,横轴为秒,纵轴为振幅
官方中文样本原始波形:约 5.62 秒、单声道 48 kHz。运行指南前应明确重采样为 16 kHz。波形图不是准确率或性能结果。
-

先用合成静音验证配置和张量形状,可以把依赖、模板错误与权重加载问题分开。但预处理没有调用生成模型,不能据此宣布“识别通过”。

-

应用上下文应该放在哪里?

-

业务词表通过原生 keywords 参数传入,相关背景通过 prompt 传入。它们不是工具包的 hotword 参数,也不是 HTTP 层字段。给两份录音分别传上下文时,语言、prompt 和嵌套关键词列表应与录音数量一致。输出顺序应回到同一份输入清单。

-

例如客服录音里的人名、产品名,可以作为候选关键词;但“模板中出现了关键词”和“模型在噪声中识别对了这个词”是两项测试。不要把拼写纠正或摘要改写后的文本冒充原始 ASR 输出。

-

当前固定快照默认左侧 padding;批量示例仍可显式设置 padding 以便阅读。生成返回值要按整批输入张量的宽度去除 prompt,不是各条 attention mask 有效长度。空录音和空列表应在应用边界提前拒绝。

+ +

一次真实短录音检查说明了什么?

在固定源码、官方 -hf revision 和 CPU float32 环境中,我们运行了中文单录音、英文单录音、中英混合批量,以及一个带关键词的中文请求。四个请求均返回文本并在上限前生成 EOS;批量结果顺序正确。这是两份公开短音频的功能检查,不是准确率排行榜。

@@ -49,9 +42,25 @@

Fun-ASR-Nano + Transformers:先选对权重,再接入应用

  • 资源。独立 CPU 示例不是 CUDA、服务并发或 vLLM 性能测试。分别记录下载、模型加载和生成,不把首次下载算成模型推理,也不把一次短录音当作容量规划。
  • 质量与隐私。在授权录音中回听关键数字、否定词和末尾,保留固定版本与原始结果;公开反馈不要携带客户录音、token 或身份信息。
  • 需要段级时间戳和匿名说话人时,可比较 OpenMOSS 的第三方 MOSS 统一转写与分离路径。匿名标签不是人物身份,模型选型仍应从应用真正需要的输出开始。

    -

    从哪里开始

    -

    先打开原生 Transformers 安装与推理指南,完成独立 CPU 环境、无权重预处理和短录音生成,再决定是否需要批量或服务化。模型页和 Model Zoo保留格式入口;会议录音验收清单帮助把结果交付到应用。

    -

    源码依据:固定提交的官方模型文档固定 revision 的模型卡。模型项目与贡献入口见 Fun-ASRFunASR

    + +

    附录:环境与接口细节

    +

    先确认安装包,不只看合并状态

    +

    PR #46180 于 2026-09-09 合并。当天核验的稳定版 5.16.1 还没有原生 fun_asr_nano 文件;源码包显示的 5.17.0.dev0 也不是下一稳定版发布承诺。请从固定版本接入指南安装明确的源码提交,不要只执行一次无版本约束的升级就假定已经兼容。

    +

    这种区别影响排障方向:未知模型类型可能是安装包未包含实现,不一定是 checkpoint 损坏;模型能下载也不代表所选运行时认识它。

    +

    从声音到文本,中间实际经过什么?

    +
    1. 音频样本。采样率与声道是数据含义的一部分。48 kHz 的数组不能只改参数标成 16 kHz;那会改变模型看到的时间尺度。指南先要求明确的单声道 16 kHz WAV。
    2. +
    3. 音频特征。原生特征提取器用 torchaudio 的 Kaldi fbank,再做 LFR 堆叠与降采样。这里仍需要匹配版本的 torchaudio;不能套用 FunASR 工具包的可选依赖结论。
    4. +
    5. 转写请求。apply_transcription_request 把音频、语言、上下文和关键词整理成模型的聊天模板,并对齐音频占位 token 与特征。
    6. +
    7. 生成和解码。AutoModelForSpeechSeq2Seq 返回 token。去掉输入 prompt 的长度后再解码,才能避免把模板混进转写文本。
    + +

    先用合成静音验证配置和张量形状,可以把依赖、模板错误与权重加载问题分开。但预处理没有调用生成模型,不能据此宣布“识别通过”。

    +

    应用上下文应该放在哪里?

    +

    业务词表通过原生 keywords 参数传入,相关背景通过 prompt 传入。它们不是工具包的 hotword 参数,也不是 HTTP 层字段。给两份录音分别传上下文时,语言、prompt 和嵌套关键词列表应与录音数量一致。输出顺序应回到同一份输入清单。

    +

    例如客服录音里的人名、产品名,可以作为候选关键词;但“模板中出现了关键词”和“模型在噪声中识别对了这个词”是两项测试。不要把拼写纠正或摘要改写后的文本冒充原始 ASR 输出。

    +

    当前固定快照默认左侧 padding;批量示例仍可显式设置 padding 以便阅读。生成返回值要按整批输入张量的宽度去除 prompt,不是各条 attention mask 有效长度。空录音和空列表应在应用边界提前拒绝。

    +

    源码依据:固定提交的官方模型文档固定 revision 的模型卡。模型项目与贡献入口见 Fun-ASRFunASR

    +
    +

    先跑通一份录音

    下一步打开固定版本的原生 Transformers 指南,在独立环境中完成短录音检查。保留原始结果,确认输出适合应用后,再扩大任务。

    FunASR · 应用实践与技术解读
    diff --git a/web-pages/product-site/legacy/blog/funasr-transcribe-long-audio.html b/web-pages/product-site/legacy/blog/funasr-transcribe-long-audio.html index fb6d8f105..c98eb3def 100644 --- a/web-pages/product-site/legacy/blog/funasr-transcribe-long-audio.html +++ b/web-pages/product-site/legacy/blog/funasr-transcribe-long-audio.html @@ -1,13 +1,11 @@ -长录音转写:VAD 分段、批处理与结果验收 | FunASR - +长录音为什么会漏掉结尾? | FunASR + - + +footer a{color:#94a3b8}article p a,article li a{color:#006a57;text-decoration:underline;text-underline-offset:.15em} +
    -

    长录音转写:VAD 分段、批处理与结果验收

    +

    长录音为什么会漏掉结尾?

    -

    播客、课程和会议录音的难点,不只是把文件交给模型,还包括资源预算、停顿与重叠语音、以及末尾是否漏识别。本文以 FunASR 1.4.15 的离线 SenseVoice + FSMN-VAD 路径为例,说明一次文件级调用内部做了什么,以及上线前该检查什么。

    - +

    如果团队拿到了长录音的文本,却找不到最后一句,先别急着调大批次。沿着原文件、语音分段和返回结果定位,才能知道哪一步需要处理;有文本并不代表全程完整。

    +

    设想一段访谈:原文件里能听到最后一句,文本里却没有。这个现象还不能直接归因于模型。先确认读取包含尾部、分段保留了那句声音,再看客户端有没有保存完整结果。

    +

    下面只用一个明确的 SenseVoice 离线分段配置说明排查路径,不是所有漏尾问题的修复。静音、切分、推理和客户端异常需要分别核验。

    + +

    调参之前,先查三个位置

    +
    1. 输入:核对实际解码的那份文件及其结尾,不是同名的另一份录音。
    2. 边界:检查缺句附近的切分。语音检测负责选区域,不负责判断文字是否正确。
    3. 结果:分清空记录、未读完的响应和最终保存的文本;保留错误,不靠猜测补写原文。

    用一个明确的离线配置开始

    前提是已经准备好的独立 FunASR 1.4.15 环境,包括 PyTorch、可用的音频解码依赖和模型权重访问。先按安装与环境验证指南准备平台依赖,再核对Python SDK 指南。下方版本检查只检查已安装包的元数据,不是完整安装或声学推理证明。

    @@ -87,20 +87,6 @@

    用一个明确的离线配置开始

    raise RuntimeError("Empty transcript; inspect audio, silence and VAD output") print(text)

    这里明确使用 CPU,按 VAD 片段逐个处理;batch_size_s 不是 CPU 并行参数,也不是速度推荐。空结果需要检查:可能是静音、VAD 未选中语音、输入问题或推理异常,不能一律当成成功或模型错误。示例故意停止并要求人工检查,不提供伪造的转写输出。

    -

    一次调用背后的资源路径

    -
    -

    这条离线路径会读取完整音频波形到 CPU 内存,再使用 VAD 区域组织 ASR 输入;它不是只从磁盘读取当前片段的有界内存流式处理。分段可以改变某一批推理的工作量,但整段波形、解码缓冲、分段信息、输出和并发请求仍占用资源。

    -

    仅按 16 kHz、单声道、float32 波形计算,一小时数据约 230 MB;这只是数据量估算,不是进程 RAM 或 GPU 峰值内存实测,也不包含解码副本和模型。不能据此承诺任意文件都能处理,或一小时与一分钟文件显存相同。可接受的时长需要在所选硬件、模型、音频格式和并发条件下验证。

    -

    查看整段加载与分段排序音频解码入口。这些源码能说明数据路径,不能替代峰值内存测量。

    -
    -

    两个参数,两个不同的边界

    -
    -
    同一份中文音频原始输出
    - - -
    参数含义不能保证什么
    max_single_segment_time=30000FSMN-VAD 的端点阈值,单位毫秒(ms)。它参与帧级语音分段决策。不是整文件时长限制,不保证严格等长片段或恰好切在一个词、句子的边界。
    batch_size_s=300VAD 包装器的批预算,单位秒(s),内部转为毫秒;组批判据涉及最长片段时长乘以片段数量。不是所有片段时长简单求和,也不是硬内存上限;较长的单段仍可能单独进入推理。
    -

    CPU 例外:当前包装器在 device="cpu" 时关闭上述组批预算,按片段逐个处理。因此调整示例中的 batch_size_s 不能当作 CPU 批吞吐优化已经生效。换用经过配置和验证的 GPU 环境时,应重新测量,而不是套用相同内存或速度结论。实现依据:批预算单位CPU 分支与组批判据

    -

    长文件上线前怎么验收

      @@ -111,6 +97,26 @@

      长文件上线前怎么验收

    更多思路见会议转写验收清单可复现性能评测。前者附带的 MOSS sentence_info 检查器有特定输入契约,不可直接套在任意 SenseVoice 返回值上;结构检查也不证明文字准确率或语音覆盖完整。

    +

    附录:窗口、资源与参数单位

    + +

    一次调用背后的资源路径

    +
    +

    这条离线路径会读取完整音频波形到 CPU 内存,再使用 VAD 区域组织 ASR 输入;它不是只从磁盘读取当前片段的有界内存流式处理。分段可以改变某一批推理的工作量,但整段波形、解码缓冲、分段信息、输出和并发请求仍占用资源。

    +

    仅按 16 kHz、单声道、float32 波形计算,一小时数据约 230 MB;这只是数据量估算,不是进程 RAM 或 GPU 峰值内存实测,也不包含解码副本和模型。不能据此承诺任意文件都能处理,或一小时与一分钟文件显存相同。可接受的时长需要在所选硬件、模型、音频格式和并发条件下验证。

    +

    查看整段加载与分段排序音频解码入口。这些源码能说明数据路径,不能替代峰值内存测量。

    +
    +

    两个参数,两个不同的边界

    +
    +
      +
    • max_single_segment_time=30000: FSMN-VAD 的端点阈值,单位毫秒(ms)。它参与帧级语音分段决策。 不是整文件时长限制,不保证严格等长片段或恰好切在一个词、句子的边界。
    • +
    • batch_size_s=300: VAD 包装器的批预算,单位秒(s),内部转为毫秒;组批判据涉及最长片段时长乘以片段数量。 不是所有片段时长简单求和,也不是硬内存上限;较长的单段仍可能单独进入推理。
    • +
    +

    CPU 例外:当前包装器在 device="cpu" 时关闭上述组批预算,按片段逐个处理。因此调整示例中的 batch_size_s 不能当作 CPU 批吞吐优化已经生效。换用经过配置和验证的 GPU 环境时,应重新测量,而不是套用相同内存或速度结论。实现依据:批预算单位CPU 分支与组批判据

    +
    +

    模型和服务应分开选择

    • 本文是明确配置外部 FSMN-VAD 的 SenseVoice 离线示例。Paraformer 的配置与输出应另行核对;SenseVoice 富文本标签不是经过验证的情绪准确率。
    • @@ -118,32 +124,8 @@

      模型和服务应分开选择

    • 需要实时麦克风转写时,按部署矩阵选择明确支持的流式模型与服务;缩小离线批次并不会把它变成流式协议。更多能力差异见模型选型指南

    本文依据固定版本源码解释行为,没有新增长录音性能或准确率实测。旧页缺乏完整复现条件的耗时和全程覆盖宣传已撤下;历史版本保留在仓库中,不能作为当前部署承诺。

    - -

    相关文章

    - +
    +

    下一步,按会议转写验收清单挑一份有代表性的录音回听结尾。该文下载的 MOSS 检查器有不同输入契约,不能直接套用本篇 SenseVoice 返回值。

    diff --git a/web-pages/product-site/legacy/blog/funclip-v2-2-0-moss-speaker-clipping.html b/web-pages/product-site/legacy/blog/funclip-v2-2-0-moss-speaker-clipping.html index 05dc8d872..f66fbcdd6 100644 --- a/web-pages/product-site/legacy/blog/funclip-v2-2-0-moss-speaker-clipping.html +++ b/web-pages/product-site/legacy/blog/funclip-v2-2-0-moss-speaker-clipping.html @@ -3,10 +3,10 @@ - FunClip v2.2.0:MOSS 长音频说话人识别与视频剪辑 | FunASR - - - + 把多人录音变成可剪辑的字幕 | FunASR + + + @@ -14,31 +14,37 @@ - + + *{box-sizing:border-box}body{margin:0;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text);line-height:1.72;background:#fff}a{color:var(--primary);text-decoration:none}a:hover{text-decoration:underline}.container{max-width:840px;margin:auto;padding:0 24px}.nav{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--border);padding:14px 0}.nav .container{max-width:1120px;display:flex;align-items:center;gap:20px}.nav-logo{font-weight:800;color:var(--text)}.nav-logo span{color:var(--primary)}.nav-links{display:flex;gap:16px;margin-left:auto}.nav-btn{padding:8px 14px;background:var(--primary);color:#fff;border-radius:7px}article{padding:64px 0 80px}h1{font-size:2.1rem;line-height:1.28;margin:0 0 12px}h2{font-size:1.35rem;margin:36px 0 12px;padding-top:20px;border-top:1px solid var(--border)}article p,article li{color:#475569}.meta{color:#64748b}.lead{font-size:1.08rem;color:var(--text)}.hero-media{width:100%;aspect-ratio:2.04/1;object-fit:cover;border:1px solid var(--border);border-radius:8px;margin:18px 0 24px}pre{overflow:auto;background:#172033;color:#e2e8f0;padding:18px;border-radius:8px;line-height:1.6}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}p code,li code,td code{background:var(--surface);padding:2px 5px;border-radius:4px;color:#1d4ed8}table{width:100%;border-collapse:collapse;margin:18px 0}th,td{text-align:left;vertical-align:top;padding:11px;border-bottom:1px solid var(--border)}th{background:var(--surface)}.proof{border-left:4px solid var(--accent);background:#f0fdf4;padding:14px 16px;color:#14532d}.cta{margin-top:30px;padding:22px;border:1px solid var(--border);border-radius:8px}.cta a{font-weight:700}footer{padding:28px;background:#0f172a;color:#94a3b8;text-align:center}@media(max-width:800px){.nav-links{display:none}h1{font-size:1.65rem}} + article p a,article li a{color:#006a57;text-decoration:underline;text-underline-offset:.15em} +
    -

    FunClip v2.2.0:用 MOSS 做长音频说话人分离与视频剪辑

    -

    2026-08-31 · FunClip Release

    - FunClip 本地视频、字幕识别和智能剪辑界面 -

    FunClip v2.2.0 新增一条可选的 MOSS 路径:把长音频交给 vLLM 服务,FunASR 将模型输出归一化为文本、匿名说话人标签和时间段,FunClip 再生成 SRT 或按 spkS01spkS02 剪辑。

    -

    MOSS-Transcribe-Diarize 是 OpenMOSS 维护的第三方模型,不属于 FunASR 或 FunClip。集成固定使用 OpenMOSS-Team/MOSS-Transcribe-Diarize revision e8681d68e7042738ffca8ac8212bc8fcb1131ab8,并明确保留模型归属与支持边界。

    -

    spkS01spkS02 只是当前录音内的匿名标签,不能识别已知人物、验证已注册声纹,也不保证不同录音中的同名标签对应同一个人。

    - -

    数据路径

    - - - - -
    阶段职责
    vLLM加载固定 MOSS revision,通过 /v1/audio/transcriptions 返回带时间与说话人标记的 JSON。
    FunASR 1.4.9+解析转写结果并生成统一的 texttimestampsentence_info
    FunClip 2.2.0渲染说话人 SRT、按说话人选段,或把时间段交给现有音视频剪辑流程。
    - -

    1. 启动固定 revision 的 vLLM 服务

    +

    把多人录音变成可剪辑的字幕

    +

    2026-08-31 · 应用实践 · 更新于 2026-09-09

    +

    如果你要从一段访谈里剪出某位嘉宾的发言,先得到能回到原声音的字幕,比先追求一整页流畅文字更有用。FunClip 可以用录音内的说话人标签选段;剪辑前仍要回听。

    +

    已有的 FunClip v2.2.0 双人样例走通了这条链路:得到 S01/S02 两个标签、生成字幕,再按 S02 剪出片段。这是下文的历史功能案例,不是“任何访谈都能剪准”的证明。

    +

    标签不是人物身份。spkS01spkS02 只属于当前录音;换一段录音,同名标签不保证对应同一个人。段级字幕也不能代替逐字精确对齐。

    +
    FunClip 本地视频、字幕识别和智能剪辑界面
    已有 FunClip v2.1.0 工作区截图,用于展示视频、字幕与选段的关系;不是上述 MOSS 样例的运行截图。
    +

    先找到发言,再决定怎么剪

    +

    MOSS-Transcribe-Diarize 是 OpenMOSS 维护的第三方模型。它提供转写和匿名说话人时间段;FunASR 整理结果,FunClip 把时间段接到字幕与音视频剪辑流程。

    +
    1. 给模型连续的录音。这条路径不接外部 VAD 或说话人模型,避免先切成独立请求后丢失录音内的说话人上下文。
    2. +
    3. 在字幕里核对目标发言。标签帮助找到候选片段,但名字、数字、抢话和转场仍要回听,不能凭标签数量验收。
    4. +
    5. 先导出一段,再批量处理。回看实际片段的开头、结尾和字幕;不要只检查剪辑命令退出成功。
    +

    哪些剪辑适合这条路径?

    + +

    开始新部署时,用维护中的 MOSS 部署指南核对运行时、显存和服务格式。下方旧命令仅解释这次发布,不替代当前环境准备;也不要把服务直接暴露到公网。

    +

    附录:v2.2.0 历史发布记录

    +

    以下保存原发布的命令、归档散列与测试口径,不是当前安装配方。其中未固定版本的 pip install -U vllm 不能保证今天的兼容性;不要直接复制升级。依赖、服务别名和请求格式应按维护指南重新核对,FunClip 命令的相对路径要求位于对应仓库根目录。

    +

    历史集成固定使用 OpenMOSS-Team/MOSS-Transcribe-Diarize revision e8681d68e7042738ffca8ac8212bc8fcb1131ab8。当时的 FunASR 1.4.9+ 集成整理 texttimestampsentence_info,FunClip 2.2.0 渲染 SRT 并剪辑;不是所有未来版本的兼容承诺。

    +

    当时的服务与请求

    python -m venv .venv-moss
     . .venv-moss/bin/activate
     pip install -U vllm
    @@ -47,41 +53,29 @@ 

    1. 启动固定 revision 的 vLLM 服务

    --revision e8681d68e7042738ffca8ac8212bc8fcb1131ab8 \ --served-model-name moss-transcribe-diarize \ --trust-remote-code --host 127.0.0.1 --port 8898
    -

    先用真实音频检查服务契约。当前经过验证的格式是 response_format=json

    +

    这次历史验证使用 response_format=json,不是对所有 MOSS 后端格式的通用说明:

    curl -fsS http://127.0.0.1:8898/v1/audio/transcriptions \
       -F file=@sample.wav \
       -F model=moss-transcribe-diarize \
       -F response_format=json \
       -F max_completion_tokens=8192
    - -

    2. 启动 FunClip

    +

    当时的 FunClip 启动参数

    python -m pip install -U -r requirements.txt
     python funclip/launch.py \
       --model moss \
       --moss-backend vllm \
       --moss-base-url http://127.0.0.1:8898/v1 \
       --moss-max-tokens 8192
    -

    远端服务需要 bearer token 时,把凭据放进 MOSS_API_KEY 环境变量;FunClip 不要求把 token 写进命令行或仓库。

    - -

    能力与边界

    -
      -
    • 支持长音频 ASR、匿名说话人标注、SRT,以及按说话人剪辑,包括不足一秒的有效短说话片段。
    • -
    • MOSS 提供段级时间戳,适合按整段或说话人剪辑;任意文本的精确字符级剪辑仍应使用 Paraformer。
    • -
    • 单次录音内的标签一致性依赖模型看到连续音频,因此不接外部 VAD 或说话人模型,避免预切块破坏标签一致性。
    • -
    • 如果最后一个 MOSS 片段因 token 上限没有结束时间戳,FunClip 会明确报错并提示提高 --moss-max-tokens,不会静默丢掉尾段。
    • -
    -

    完整生产部署、健康检查和容量边界见 MOSS 双语部署指南

    - -

    下载与校验 v2.2.0

    +

    需要 bearer token 时通过 MOSS_API_KEY 环境变量提供,不把凭据放进命令行或仓库。

    +

    归档与功能检查

    FunClip-2.2.0.tar.gz
     SHA256 994c5d9cf392b74b36284d526eca8bada1560a3e7825ab7baa9c673a1b4ef216
     
     FunClip-2.2.0.zip
     SHA256 4f5a7d33d9ea65467f29b55b15ed5be18e64de7e57e2f9f36fe51a23b40557e7
    -

    FunClip v2.2.0 Release 下载归档和 SHA256SUMS,不要从不明镜像复制二进制或凭据。

    - -
    发布内容对应 commit c205bf32a8b11226ff5e8acb9a3c7a1f00cd3b06。仓库测试结果为 84 passed、1 skipped;H100 + vLLM 两说话人样例返回 S01/S02、有效 SRT,并成功按 S02 剪辑。这个验证证明发布链路可用,不代表所有语言、音频条件或并发规模。
    -

    先按部署指南跑通一段真实双人音频,再进入 FunClip 处理自己的视频。

    查看 FunClip v2.2.0 发布与下载
    +

    归档和 SHA256SUMS 来自 FunClip v2.2.0 Release。对应 commit c205bf32a8b11226ff5e8acb9a3c7a1f00cd3b06 的历史仓库测试为 84 passed、1 skipped;H100 + vLLM 双人样例返回 S01/S02、有效 SRT,并按 S02 剪辑。这不是本次编辑重跑的结果,也不代表所有语言、音频条件或并发规模。

    +
    +

    下一步:从一段获授权的短访谈开始,按MOSS 部署指南跑通同一条链路,并人工回看第一个导出的片段。

    diff --git a/web-pages/product-site/legacy/blog/meeting-transcript-acceptance.html b/web-pages/product-site/legacy/blog/meeting-transcript-acceptance.html index 942019194..3b1b72503 100644 --- a/web-pages/product-site/legacy/blog/meeting-transcript-acceptance.html +++ b/web-pages/product-site/legacy/blog/meeting-transcript-acceptance.html @@ -1,18 +1,21 @@ -HTTP 200 不等于转写成功:会议录音的验收清单 | FunASR - - +会议转写,怎样才算做好了? | FunASR + + - - + +
    -

    HTTP 200 不等于转写成功:会议录音的验收清单

    +

    会议转写,怎样才算做好了?

    2026-09-09 · 应用工程 · 阅读约 8 分钟

    -

    一次请求返回了文本,离“这份会议记录可以交给同事”还有多远?把 ASR 接进字幕、会议纪要或按说话人剪辑时,需要分别验收内容、说话人、时间和处理完整性。下面用 FunASR 的 MOSS 结果讲清这些层次,并提供一个只依赖 Python 标准库的结构检查脚本。

    +

    团队准备交付会议纪要或字幕时,需要确认名字、决策和原声音能对得上,而不只是“接口返回了文本”。自动检查可以找出结构问题,交付质量仍需要回听和人工确认。

    +

    下文的合成案例是一份12秒录音结果,最后一段到第8秒结束。剩下4秒值得回听,却不能直接算成“漏识别了4秒”:那里也可能只有静音。

    +

    检查器没有听过录音。两个说话人标签、或接近终点的时间戳,都不等于转写正确;录音内的匿名标签也不是人物身份。

    FunClip 的视频、字幕与剪辑工作区
    应用出口示意:已有 FunClip 工作区。不是下文合成样例的运行截图。

    先分清四个问题

    @@ -53,18 +56,21 @@

    从结构检查走到人工验收

    1. 固定输入和配置。保留原音频散列、时长、采样率/声道、模型 revision、服务版本、请求格式和生成上限。记录重采样、混音与裁剪;裁掉静音后必须保存偏移映射,否则时间戳无法回到原视频。
    2. 回听高风险位置。开头与结尾、长静音两侧、说话人交替、重叠发言、低音量以及关键数字。抽查能发现问题,但抽查通过不等于整段准确。
    3. -
    4. 把文本与说话人分开评估。有人工参考转写时,可按统一的标点、大小写及分词规则计算 CER/WER;有人工说话人时间标注时,再使用说话人指标。不要把“输出了两个标签”作为 diarization 的通过条件。
    5. -
    6. 冻结说话人评测口径。pyannote.metrics 提供说话人错误率等指标;边界宽容区间(collar)和是否计入重叠语音会影响结果。比较系统时必须使用同一标注与配置,指标数字不能脱离这些前提。
    7. + +
    8. 完整性异常先定位,再调参。检查响应是否因生成上限中止、结构是否完整、客户端是否漏读。增大 token 上限只可能缓解某些截断,不保证召回;不能用“接口正常返回”关闭用户的问题。

    上线前留下什么

    遵循授权范围和保留期限,只保存必要的数据,并限制对录音与说话人标注的访问。一份可复查的交付应包含原音频标识、固定配置、原始结果、结构报告和人工确认记录。会议纪要保留到原文的回链;字幕检查可读性、定位及隐私;剪辑回看实际片段,不能只看时间段长度。涉及已知人物身份时另行设计授权、登记和验证流程,不把匿名聚类标签改名后当作身份识别。

    -

    这篇文章的检查器是进入验收流程的第一道结构检查,不是模型排行榜,也不会替代回听与标注。下一步可以沿 部署中心跑通实际工作负载,再到 FunASR 仓库查看实现或提交带最小复现的问题。

    -

    来源与复现入口

    + +

    附录:评测口径与来源

    +
    • 把文本与说话人分开评估。有人工参考转写时,可按统一的标点、大小写及分词规则计算 CER/WER;有人工说话人时间标注时,再使用说话人指标。不要把“输出了两个标签”作为 diarization 的通过条件。
    • 冻结说话人评测口径。pyannote.metrics 提供说话人错误率等指标;边界宽容区间(collar)和是否计入重叠语音会影响结果。比较系统时必须使用同一标注与配置,指标数字不能脱离这些前提。
    +
    +

    下一步,下载只读结构检查器,先跑通上面明确标注的合成案例。用报告安排回听位置,不把它当作会议转写的质量合格证。

    FunASR · 应用实践与技术解读
    diff --git a/web-pages/product-site/legacy/blog/self-hosted-openai-whisper-api-alternative.html b/web-pages/product-site/legacy/blog/self-hosted-openai-whisper-api-alternative.html index bfb463448..bf980d6d8 100644 --- a/web-pages/product-site/legacy/blog/self-hosted-openai-whisper-api-alternative.html +++ b/web-pages/product-site/legacy/blog/self-hosted-openai-whisper-api-alternative.html @@ -1,13 +1,11 @@ -自托管 OpenAI Whisper API 替代:FunASR 接入与安全边界 | FunASR Blog - +把语音转写接进自己的 API | FunASR + - +
    -

    自托管 OpenAI Whisper API 替代:FunASR 接入与安全边界

    +

    把语音转写接进自己的 API

    -

    把转写任务放到自己的机器上,能让团队自行选择模型、容量与数据处理方式。本文讨论 FunASR 1.4.15 的包内 funasr-server,演示 POST /v1/audio/transcriptions 的基本 JSON 调用。它提供部分 OpenAI 兼容能力,不代表完整复刻 OpenAI 服务,也不代表任意 SDK 或应用都无需适配。

    +

    如果应用只需要上传一段录音、取回文字,先把这一个来回走通。团队可以先在本机核对请求与返回值,再决定怎样承接真实流量,而不是一开始就复制整套云服务。

    +

    这里用一份获授权的 audio.wav:客户端把文件发给本机 SenseVoice 服务,再读取 JSON 的 text 字段。示例展示调用路径,不编造转写结果,也不比较速度。

    +

    本文使用 FunASR 1.4.15 包内的 funasr-server。它只提供部分 OpenAI 转写接口,不是所有云功能和返回格式的替代品;连通 URL 还不等于迁移完成。

    检查层次要回答的问题不能用什么代替
    @@ -95,42 +108,8 @@

    包内服务的兼容范围

    项目当前行为与接入要求
    请求multipart 文件上传;路由声明 filemodellanguageresponse_format 和 FunASR 扩展 spk。语言和说话人能力仍受所选模型限制。
    其他参数与协议prompttemperaturetimestamp_granularities 不是该路由声明的完整能力。不要把它当成流式 WebSocket、翻译或完整云 API;额外字段未报错也不证明生效。

    包内服务的字段应通过私有或受限的运维路径访问运行中服务的 /openapi.json,并核对固定版本包内处理器源码示例服务 API schema描述的是另一种实现,不是包内处理器的完整字段说明。包内服务、仓库示例、vLLM 与 llama.cpp 的默认值、参数和响应结构并不通用。

    -

    接入 Open WebUI 或其他应用前

    - -

    比较的是完整部署方案,不只是 API 单价

    -

    自托管需要计算服务器、存储、流量、维护与监控成本。是否留在受控网络,取决于客户端、代理、临时文件、日志和保留策略;不能承诺音频绝不落盘或转写永不泄露。模型权重的许可也需要单独核对,不能由工具包代码许可代替。

    -

    根据语言、时延、硬件和说话人需求查看模型选型MOSS 转写与说话人分离部署矩阵。本文不提供新的准确率或性能排名;验收方法见会议录音验收清单

    - -

    相关文章

    - + +

    下一步,在让其他用户接入前,按服务安全指南验证网关鉴权和后端不可绕过。在这些部署检查通过前,保留本机私有调用范围。

    diff --git a/web-pages/product-site/legacy/en/blog/fun-asr-nano-transformers.html b/web-pages/product-site/legacy/en/blog/fun-asr-nano-transformers.html index 1874e3e2c..30e1ef1e6 100644 --- a/web-pages/product-site/legacy/en/blog/fun-asr-nano-transformers.html +++ b/web-pages/product-site/legacy/en/blog/fun-asr-nano-transformers.html @@ -1,41 +1,34 @@ -Fun-ASR-Nano + Transformers: choose the checkpoint before the API | FunASR - - +Choose the right checkpoint for Transformers | FunASR + + - - + +
    -

    Fun-ASR-Nano + Transformers: choose the checkpoint before the API

    +

    Choose the right checkpoint for Transformers

    2026-09-09 · Ecosystem engineering · 7-minute read

    -

    When adding speech recognition to a Hugging Face application, a common failure is treating one model family as one interchangeable checkpoint format. Fun-ASR-Nano is now on the Transformers main branch. Here is how to connect the new native path to an application, from model selection to decoding the generated result.

    -

    Check the package, not just the merge

    -

    PR #46180 merged on 2026-09-09. The stable 5.16.1 package checked that day did not contain native fun_asr_nano files. The source build's 5.17.0.dev0 label is not a promise of the next stable version or release date. Use the explicit source commit in the pinned setup guide; an unversioned upgrade alone is not evidence of compatibility.

    -

    This distinction changes troubleshooting: an unknown model type may mean that the installed package lacks the implementation, not that the checkpoint is corrupt. A successful model download does not prove that the selected runtime can load it.

    +

    If you are adding speech recognition to an existing Transformers application, choose the checkpoint for that interface first. A shared model name does not make toolkit, serving and native Python weights interchangeable.

    +

    For example, a checkpoint may download successfully while the application reports an unknown model type. Check both the installed implementation and the selected artifact before assuming the weights are damaged. The native path here uses the official -hf checkpoint.

    +

    This is a text-generation interface, not a ready-made service. It does not supply word timestamps, speaker separation or streaming simply because loading succeeds.

    + +
    Official Chinese functional-test sample waveform, time in seconds and amplitude
    The official Chinese sample: approximately 5.62 seconds, mono, originally 48 kHz. Explicitly resample it to 16 kHz before the guide's input step. A waveform is not an accuracy or performance result.

    One family, four different paths

    -
    - - - - -
    Your application interfaceArtifactImportant distinction
    FunASR AutoModelFun-ASR-Nano-2512Original toolkit path; split-engine has its own guide.
    Transformers processor / generateFun-ASR-Nano-2512-hfThe native path described here, not a vLLM conversion.
    Native vLLM HTTP serviceFun-ASR-Nano-2512-vllmSeparate runtime, checkpoint format and service options.
    C++ / GGML runtimeMatching converted GGUF filesNot a renamed Transformers directory.
    +
      +
    • FunASR AutoModel: Fun-ASR-Nano-2512. Original toolkit path; split-engine has its own guide.
    • +
    • Transformers processor / generate: Fun-ASR-Nano-2512-hf. The native path described here, not a vLLM conversion.
    • +
    • Native vLLM HTTP service: Fun-ASR-Nano-2512-vllm. Separate runtime, checkpoint format and service options.
    • +
    • C++ / GGML runtime: Matching converted GGUF files. Not a renamed Transformers directory.
    • +

    The official model repositories are under FunAudioLLM. Native Transformers loads the official -hf checkpoint, while the model and processor code come from the pinned Transformers installation. It does not need to execute checkpoint-provided remote Python code. That removes one integration dependency; it does not supply authentication, queues or service monitoring.

    For concurrent clients, inspect the native vLLM deployment. For offline C++, inspect llama.cpp deployment. A composable Python interface is not a replacement for every serving stack.

    -

    What happens between audio and text?

    -
    1. Audio samples. Sample rate and channel layout are part of the data's meaning. Labeling a 48 kHz array as 16 kHz changes the time scale seen by the model. The guide requires an explicitly prepared mono 16 kHz WAV.
    2. -
    3. Audio features. The native extractor computes Kaldi fbank through torchaudio, then applies low-frame-rate stacking and subsampling. Matching torchaudio is required here, regardless of the toolkit's optional-dependency policy.
    4. -
    5. The transcription request. apply_transcription_request prepares the checkpoint's chat template from audio, language, context and keywords, aligning audio placeholder tokens with features.
    6. -
    7. Generation and decoding. AutoModelForSpeechSeq2Seq returns tokens. Remove the input prompt width before decoding to avoid mixing template framing into recognized speech.
    -
    Official Chinese functional-test sample waveform, time in seconds and amplitude
    The official Chinese sample: approximately 5.62 seconds, mono, originally 48 kHz. Explicitly resample it to 16 kHz before the guide's input step. A waveform is not an accuracy or performance result.
    -

    A synthetic-silence preprocessing check separates dependency and template failures from weight loading. It does not run the generation model, so it cannot establish successful speech recognition.

    -

    Where does application context belong?

    -

    Pass a vocabulary through native keywords and relevant background through prompt. These are not the toolkit's hotword argument or HTTP fields. For separate recording contexts, language, prompt and nested keyword lists must match the number of audio inputs. Map results back to the same input manifest in order.

    -

    Customer-service recordings may benefit from candidate product or person names. But “the template includes this keyword” and “the recognizer recovered it in noise” are different tests. Do not present a spelling correction or summary rewrite as raw ASR output.

    -

    The pinned checkpoint defaults to left padding; explicit padding still makes a batch example easier to inspect. Remove the full input tensor width from generated sequences, not each row's valid attention-mask length. Reject empty recordings and empty batches at your application boundary.

    + +

    What did the short-recording check establish?

    With pinned source, official -hf revision and CPU float32, we ran a Chinese recording, an English recording, a mixed Chinese/English batch and a Chinese keyword request. All four requests returned text and EOS before the limit, with the batch in input order. These are functional checks on two public short recordings, not an accuracy ranking.

    @@ -49,9 +42,25 @@

    Fun-ASR-Nano + Transformers: choose the checkpoint before the API

  • Resources. A CPU example is not a CUDA, concurrent-serving or vLLM performance test. Measure download, model loading and generation separately. One short file is not capacity planning.
  • Quality and privacy. Review important numbers, negation and the recording's end in authorized audio. Preserve fixed versions and raw output. Never attach customer recordings, tokens or identity data to a public report.
  • For segment timestamps and anonymous speakers, compare the third-party OpenMOSS MOSS unified transcription and diarization path. Anonymous labels are not known-person identities. Choose the output the application actually needs.

    -

    Start with a reproducible recording

    -

    Follow the native Transformers installation and inference guide: an independent CPU environment, preprocessing without weights, then a short recording. Only then extend to batching or serving. The Model Zoo preserves format-specific entry points; the meeting acceptance checklist covers downstream delivery.

    -

    Primary sources: official model documentation at the merged commit and the pinned model card. Explore the model and contribute through Fun-ASR and FunASR.

    + +

    Appendix: environment and interface details

    +

    Check the package, not just the merge

    +

    PR #46180 merged on 2026-09-09. The stable 5.16.1 package checked that day did not contain native fun_asr_nano files. The source build's 5.17.0.dev0 label is not a promise of the next stable version or release date. Use the explicit source commit in the pinned setup guide; an unversioned upgrade alone is not evidence of compatibility.

    +

    This distinction changes troubleshooting: an unknown model type may mean that the installed package lacks the implementation, not that the checkpoint is corrupt. A successful model download does not prove that the selected runtime can load it.

    +

    What happens between audio and text?

    +
    1. Audio samples. Sample rate and channel layout are part of the data's meaning. Labeling a 48 kHz array as 16 kHz changes the time scale seen by the model. The guide requires an explicitly prepared mono 16 kHz WAV.
    2. +
    3. Audio features. The native extractor computes Kaldi fbank through torchaudio, then applies low-frame-rate stacking and subsampling. Matching torchaudio is required here, regardless of the toolkit's optional-dependency policy.
    4. +
    5. The transcription request. apply_transcription_request prepares the checkpoint's chat template from audio, language, context and keywords, aligning audio placeholder tokens with features.
    6. +
    7. Generation and decoding. AutoModelForSpeechSeq2Seq returns tokens. Remove the input prompt width before decoding to avoid mixing template framing into recognized speech.
    + +

    A synthetic-silence preprocessing check separates dependency and template failures from weight loading. It does not run the generation model, so it cannot establish successful speech recognition.

    +

    Where does application context belong?

    +

    Pass a vocabulary through native keywords and relevant background through prompt. These are not the toolkit's hotword argument or HTTP fields. For separate recording contexts, language, prompt and nested keyword lists must match the number of audio inputs. Map results back to the same input manifest in order.

    +

    Customer-service recordings may benefit from candidate product or person names. But “the template includes this keyword” and “the recognizer recovered it in noise” are different tests. Do not present a spelling correction or summary rewrite as raw ASR output.

    +

    The pinned checkpoint defaults to left padding; explicit padding still makes a batch example easier to inspect. Remove the full input tensor width from generated sequences, not each row's valid attention-mask length. Reject empty recordings and empty batches at your application boundary.

    +

    Primary sources: official model documentation at the merged commit and the pinned model card. Explore the model and contribute through Fun-ASR and FunASR.

    +
    +

    Try one recording

    Open the pinned native Transformers guide and complete its short-recording check in a separate environment. Keep the raw result and verify that the output matches your application before expanding the workload.

    FunASR · Applications and technical guides
    diff --git a/web-pages/product-site/legacy/en/blog/funasr-transcribe-long-audio.html b/web-pages/product-site/legacy/en/blog/funasr-transcribe-long-audio.html index 2826b1153..2ea26c3b1 100644 --- a/web-pages/product-site/legacy/en/blog/funasr-transcribe-long-audio.html +++ b/web-pages/product-site/legacy/en/blog/funasr-transcribe-long-audio.html @@ -1,13 +1,11 @@ -Long-File Transcription: VAD Segmentation, Batching and Acceptance | FunASR - +Why can a long transcript miss the ending? | FunASR + - + +footer a{color:#94a3b8}article p a,article li a{color:#006a57;text-decoration:underline;text-underline-offset:.15em} +
    -

    Long-File Transcription: VAD Segmentation, Batching and Acceptance

    +

    Why can a long transcript miss the ending?

    -

    Podcasts, courses and meeting recordings require more than passing a file to a model: resource budgets, pauses, overlapping speech and missing tail speech all matter. This guide uses the offline SenseVoice + FSMN-VAD path in FunASR 1.4.15 to explain what happens inside a file-level call and what to check before rollout.

    - +

    If your team has a transcript but cannot find the final sentence, do not begin by increasing a batch parameter. Trace the recording, the selected speech regions and the returned result first. A nonempty response does not prove complete coverage.

    +

    Consider an interview whose last remark is audible in the original file but absent from the transcript. That observation alone does not identify a model defect: establish whether the loaded audio includes the end, whether segmentation retains it and whether the client saves the full response.

    +

    The example below is one offline SenseVoice configuration with an external speech detector, not a fix for every missing tail. Silence, segmentation and inference or client failures need different evidence.

    + +

    Three places to look before changing parameters

    +
    1. The input: listen to the end of the same decoded file, not another copy with a similar name.
    2. The boundary: inspect cuts near the missing sentence. Speech detection chooses regions; it does not verify the words.
    3. The result: distinguish an empty record, incomplete response and saved text. Retain errors rather than filling a missing sentence by guesswork.

    Start with one explicit offline configuration

    Use an already prepared, separate FunASR 1.4.15 environment with PyTorch, working audio-decoding dependencies and access to model weights. Follow the installation and environment guide for platform preparation, then check the Python SDK guide. The version check below inspects installed package metadata; it is not proof of a complete installation or acoustic inference.

    @@ -87,20 +87,6 @@

    Start with one explicit offline configuration

    raise RuntimeError("Empty transcript; inspect audio, silence and VAD output") print(text)

    This explicit CPU path handles VAD regions individually; batch_size_s is not a CPU parallelism control or a speed recommendation. Empty output needs inspection: silence, VAD selection, input problems or inference errors are possible causes. It is not automatically success or a model defect. The example deliberately stops for inspection and supplies no fabricated transcript output.

    -

    The resource path inside a file call

    -
    -

    This offline path loads the complete audio waveform into CPU memory, then organizes ASR inputs using VAD regions. It is not a bounded-memory stream that reads only the current segment from disk. Segmentation can change the work in one inference batch, but the full waveform, decoding buffers, segment metadata, outputs and concurrent requests still consume resources.

    -

    For a 16 kHz, mono, float32 waveform alone, one hour is about 230 MB. This is a data-size estimate, not measured peak process RAM or GPU memory, and excludes decoder copies and model state. It cannot establish support for every file or equal GPU memory for one-hour and one-minute inputs. Validate acceptable durations on the chosen hardware, model, audio format and concurrency.

    -

    See whole-file loading and segment sorting and the audio-decoding entry point. Source inspection explains the data path; it does not replace peak-memory measurement.

    -
    -

    Two parameters with different boundaries

    -
    -
    The same Chinese audioRaw output
    - - -
    ParameterMeaningWhat it does not guarantee
    max_single_segment_time=30000An FSMN-VAD endpoint threshold in milliseconds (ms), used in frame-level speech segmentation decisions.It is not a file-duration limit or a guarantee of exactly equal segments or cuts at word and sentence boundaries.
    batch_size_s=300A VAD-wrapper batch budget in seconds (s), converted to milliseconds internally. Grouping considers the longest segment duration multiplied by the number of segments.It is not simply the sum of segment durations or a hard memory cap; a longer segment may still be processed alone.
    -

    CPU exception: the current wrapper disables that grouping budget for device="cpu" and handles segments individually. Changing batch_size_s in this example is therefore not evidence of CPU batch-throughput optimization. A prepared and validated GPU setup needs new measurements, not inherited memory or speed conclusions. See budget units and the CPU branch and grouping rule.

    -

    Acceptance before admitting long recordings

      @@ -111,6 +97,26 @@

      Acceptance before admitting long recordings

    See the meeting-transcript acceptance checklist and reproducible performance guide. The former's MOSS sentence_info audit script has a specific input contract, not an adapter for arbitrary SenseVoice results. Structural checks do not prove text accuracy or complete speech coverage.

    +

    Appendix: windows, resources and parameter units

    + +

    The resource path inside a file call

    +
    +

    This offline path loads the complete audio waveform into CPU memory, then organizes ASR inputs using VAD regions. It is not a bounded-memory stream that reads only the current segment from disk. Segmentation can change the work in one inference batch, but the full waveform, decoding buffers, segment metadata, outputs and concurrent requests still consume resources.

    +

    For a 16 kHz, mono, float32 waveform alone, one hour is about 230 MB. This is a data-size estimate, not measured peak process RAM or GPU memory, and excludes decoder copies and model state. It cannot establish support for every file or equal GPU memory for one-hour and one-minute inputs. Validate acceptable durations on the chosen hardware, model, audio format and concurrency.

    +

    See whole-file loading and segment sorting and the audio-decoding entry point. Source inspection explains the data path; it does not replace peak-memory measurement.

    +
    +

    Two parameters with different boundaries

    +
    +
      +
    • max_single_segment_time=30000: An FSMN-VAD endpoint threshold in milliseconds (ms), used in frame-level speech segmentation decisions. It is not a file-duration limit or a guarantee of exactly equal segments or cuts at word and sentence boundaries.
    • +
    • batch_size_s=300: A VAD-wrapper batch budget in seconds (s), converted to milliseconds internally. Grouping considers the longest segment duration multiplied by the number of segments. It is not simply the sum of segment durations or a hard memory cap; a longer segment may still be processed alone.
    • +
    +

    CPU exception: the current wrapper disables that grouping budget for device="cpu" and handles segments individually. Changing batch_size_s in this example is therefore not evidence of CPU batch-throughput optimization. A prepared and validated GPU setup needs new measurements, not inherited memory or speed conclusions. See budget units and the CPU branch and grouping rule.

    +
    +

    Choose models and services separately

    • This is an offline SenseVoice example with explicitly configured external FSMN-VAD. Check Paraformer configuration and outputs separately. SenseVoice rich-text tags are not verified emotion accuracy.
    • @@ -118,32 +124,8 @@

      Choose models and services separately

    • For realtime microphone transcription, select an explicitly supported streaming model and service using the deployment matrix. Smaller offline batches do not create a streaming protocol. See model selection for capability differences.

    This article explains pinned source behavior, not new long-recording performance or accuracy measurements. The previous page's timing and full-coverage promotion lacked complete reproduction conditions and has been removed. Repository history preserves that text; it is not a current deployment promise.

    - -

    Related posts

    - +
    +

    Next, use the meeting-transcript checklist to choose what to listen for at the end of a representative recording. Its downloadable MOSS checker has a different input contract from this SenseVoice recipe.

    diff --git a/web-pages/product-site/legacy/en/blog/funclip-v2-2-0-moss-speaker-clipping.html b/web-pages/product-site/legacy/en/blog/funclip-v2-2-0-moss-speaker-clipping.html index 9b1e830d3..d7a56444a 100644 --- a/web-pages/product-site/legacy/en/blog/funclip-v2-2-0-moss-speaker-clipping.html +++ b/web-pages/product-site/legacy/en/blog/funclip-v2-2-0-moss-speaker-clipping.html @@ -3,10 +3,10 @@ - FunClip v2.2.0: MOSS Speaker-Aware Video Clipping | FunASR - - - + Turn a conversation into editable subtitles | FunASR + + + @@ -14,31 +14,37 @@ - + + *{box-sizing:border-box}body{margin:0;font-family:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--text);line-height:1.72;background:#fff}a{color:var(--primary);text-decoration:none}a:hover{text-decoration:underline}.container{max-width:840px;margin:auto;padding:0 24px}.nav{position:sticky;top:0;z-index:10;background:#fff;border-bottom:1px solid var(--border);padding:14px 0}.nav .container{max-width:1120px;display:flex;align-items:center;gap:20px}.nav-logo{font-weight:800;color:var(--text)}.nav-logo span{color:var(--primary)}.nav-links{display:flex;gap:16px;margin-left:auto}.nav-btn{padding:8px 14px;background:var(--primary);color:#fff;border-radius:7px}article{padding:64px 0 80px}h1{font-size:2.1rem;line-height:1.28;margin:0 0 12px}h2{font-size:1.35rem;margin:36px 0 12px;padding-top:20px;border-top:1px solid var(--border)}article p,article li{color:#475569}.meta{color:#64748b}.lead{font-size:1.08rem;color:var(--text)}.hero-media{width:100%;aspect-ratio:2.04/1;object-fit:cover;border:1px solid var(--border);border-radius:8px;margin:18px 0 24px}pre{overflow:auto;background:#172033;color:#e2e8f0;padding:18px;border-radius:8px;line-height:1.6}code{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}p code,li code,td code{background:var(--surface);padding:2px 5px;border-radius:4px;color:#1d4ed8}table{width:100%;border-collapse:collapse;margin:18px 0}th,td{text-align:left;vertical-align:top;padding:11px;border-bottom:1px solid var(--border)}th{background:var(--surface)}.proof{border-left:4px solid var(--accent);background:#f0fdf4;padding:14px 16px;color:#14532d}.cta{margin-top:30px;padding:22px;border:1px solid var(--border);border-radius:8px}.cta a{font-weight:700}footer{padding:28px;background:#0f172a;color:#94a3b8;text-align:center}@media(max-width:800px){.nav-links{display:none}h1{font-size:1.65rem}} + article p a,article li a{color:#006a57;text-decoration:underline;text-underline-offset:.15em} +
    -

    FunClip v2.2.0: Long-Form Speaker-Aware Video Clipping with MOSS

    -

    August 31, 2026 · FunClip Release

    - FunClip local video, subtitle recognition, and intelligent clipping interface -

    FunClip v2.2.0 adds an opt-in MOSS path: send long audio to a vLLM service, normalize text, anonymous speaker labels, and time ranges through FunASR, then generate SRT or clip by spkS01, spkS02, and later speaker labels.

    -

    MOSS-Transcribe-Diarize is a third-party model maintained by OpenMOSS, not a FunASR or FunClip-owned checkpoint. The integration pins OpenMOSS-Team/MOSS-Transcribe-Diarize revision e8681d68e7042738ffca8ac8212bc8fcb1131ab8 and keeps ownership and support boundaries explicit.

    -

    spkS01 and spkS02 distinguish anonymous speakers within the current recording. The model does not identify a known person, verify an enrolled voiceprint, or promise that labels match across separate recordings.

    - -

    Data path

    - - - - -
    StageResponsibility
    vLLMLoads the pinned MOSS revision and serves timestamped speaker markup through /v1/audio/transcriptions.
    FunASR 1.4.9+Parses the response into common text, timestamp, and sentence_info fields.
    FunClip 2.2.0Renders speaker-labeled SRT and sends selected speaker ranges into the existing audio/video clipping workflow.
    - -

    1. Start vLLM at the pinned revision

    +

    Turn a conversation into editable subtitles

    +

    August 31, 2026 · Applications · Updated September 9, 2026

    +

    If you need one guest's remarks from an interview, subtitles that lead back to the sound are more useful than a page of fluent text. FunClip can select ranges by recording-local speaker labels; you still need to listen before accepting the cut.

    +

    An existing FunClip v2.2.0 two-speaker example completed that sequence: obtain S01/S02 labels, produce subtitles, then cut the S02 ranges. This historical functional case anchors the article; it does not prove that every interview will be edited correctly.

    +

    A recording-local label does not identify a known person. spkS01 and spkS02 belong to this recording, with no promise of the same person behind a label in another file. Segment subtitles are not precise word alignment.

    +
    FunClip local video, subtitle recognition, and intelligent clipping interface
    An existing FunClip v2.1.0 workspace shows video, subtitles and selection. It is not a screenshot of the MOSS example described above.
    +

    Find the remark before choosing the cut

    +

    MOSS-Transcribe-Diarize is a third-party model maintained by OpenMOSS. It supplies transcription and anonymous speaker time ranges; FunASR normalizes the result, and FunClip connects the ranges to subtitles and media editing.

    +
    1. Keep the recording context. This path uses no external VAD or speaker model. Splitting it into independent requests first can lose the context needed for consistent recording-local labels.
    2. +
    3. Check the target remarks in the subtitles. Labels locate candidate passages. Listen to names, numbers, interruptions and transitions rather than treating a label count as acceptance.
    4. +
    5. Export one cut before scaling up. Replay its start, end and subtitles. A successful editing command is not enough.
    +

    Which edits fit this path?

    + +

    For a new deployment, use the maintained MOSS guide to check runtime, memory and response format. The old commands below explain this release, not current environment preparation. Do not expose the backend directly to the public network.

    +

    Appendix: the historical v2.2.0 release

    +

    The original commands, archive hashes and verification scope follow. They are not a current installation recipe. In particular, the unpinned pip install -U vllm is not a compatibility guarantee today; do not copy it as an upgrade instruction. Recheck dependencies, served model names and formats in the maintained guide. Relative FunClip commands assume the corresponding repository root.

    +

    The historical integration used OpenMOSS-Team/MOSS-Transcribe-Diarize revision e8681d68e7042738ffca8ac8212bc8fcb1131ab8. Its FunASR 1.4.9+ integration normalized text, timestamp and sentence_info; FunClip 2.2.0 rendered SRT and performed the cuts. This is not a compatibility promise for every future version.

    +

    The service and request used then

    python -m venv .venv-moss
     . .venv-moss/bin/activate
     pip install -U vllm
    @@ -47,41 +53,29 @@ 

    1. Start vLLM at the pinned revision

    --revision e8681d68e7042738ffca8ac8212bc8fcb1131ab8 \ --served-model-name moss-transcribe-diarize \ --trust-remote-code --host 127.0.0.1 --port 8898
    -

    Verify the service with real audio before starting FunClip. The tested contract uses response_format=json:

    +

    This historical check used response_format=json; it is not a universal format contract for every MOSS backend:

    curl -fsS http://127.0.0.1:8898/v1/audio/transcriptions \
       -F file=@sample.wav \
       -F model=moss-transcribe-diarize \
       -F response_format=json \
       -F max_completion_tokens=8192
    - -

    2. Start FunClip

    +

    The FunClip launch options used then

    python -m pip install -U -r requirements.txt
     python funclip/launch.py \
       --model moss \
       --moss-backend vllm \
       --moss-base-url http://127.0.0.1:8898/v1 \
       --moss-max-tokens 8192
    -

    For an authenticated remote service, put the bearer credential in the MOSS_API_KEY environment variable. FunClip does not require putting the token in the command line or repository.

    - -

    Capabilities and boundaries

    -
      -
    • Long-form ASR, anonymous speaker labeling, SRT, and speaker-based clipping are supported, including valid speaker turns shorter than one second.
    • -
    • MOSS provides segment-level timestamps. Use it for whole-segment or speaker clipping; keep Paraformer for precise arbitrary character-level text clipping.
    • -
    • Consistent speaker assignment within one recording depends on continuous context, so the MOSS path attaches no external VAD or speaker model that would pre-chunk the recording.
    • -
    • If the final segment is missing its ending timestamp after token exhaustion, FunClip raises an explicit truncation error and asks for a higher --moss-max-tokens value instead of silently dropping the tail.
    • -
    -

    See the bilingual MOSS production guide for health checks, runtime choices, and capacity boundaries.

    - -

    Download and verify v2.2.0

    +

    When bearer authentication is required, provide it through MOSS_API_KEY, not command arguments or the repository.

    +

    Archives and functional checks

    FunClip-2.2.0.tar.gz
     SHA256 994c5d9cf392b74b36284d526eca8bada1560a3e7825ab7baa9c673a1b4ef216
     
     FunClip-2.2.0.zip
     SHA256 4f5a7d33d9ea65467f29b55b15ed5be18e64de7e57e2f9f36fe51a23b40557e7
    -

    Download the archives and SHA256SUMS from the FunClip v2.2.0 release.

    - -
    Release content resolves to commit c205bf32a8b11226ff5e8acb9a3c7a1f00cd3b06. The repository suite completed with 84 passed and 1 skipped. A live H100 + vLLM two-speaker run returned S01/S02, valid SRT, and the expected S02 clip. This verifies the release path, not every language, audio condition, or production concurrency level.
    -

    Run one real two-speaker recording through the production guide, then bring the same service into FunClip.

    Open the FunClip v2.2.0 release and downloads
    +

    Archives and SHA256SUMS are in the FunClip v2.2.0 release. Commit c205bf32a8b11226ff5e8acb9a3c7a1f00cd3b06 had 84 passed and 1 skipped repository tests; the H100 + vLLM two-speaker case returned S01/S02, valid SRT and an S02 cut. These are historical results, not reruns for this edit or evidence for every language, recording condition or concurrency level.

    +
    +

    Next, use one short interview you are authorized to process. Follow the MOSS deployment guide through the same path, then replay the first exported cut.

    diff --git a/web-pages/product-site/legacy/en/blog/meeting-transcript-acceptance.html b/web-pages/product-site/legacy/en/blog/meeting-transcript-acceptance.html index 6d202db53..a54ff3997 100644 --- a/web-pages/product-site/legacy/en/blog/meeting-transcript-acceptance.html +++ b/web-pages/product-site/legacy/en/blog/meeting-transcript-acceptance.html @@ -1,18 +1,21 @@ -HTTP 200 is not a usable transcript: a meeting-audio acceptance checklist | FunASR - - +When is a meeting transcript ready to use? | FunASR + + - - + +
    -

    HTTP 200 is not a usable transcript: a meeting-audio acceptance checklist

    +

    When is a meeting transcript ready to use?

    2026-09-09 · Application engineering · 8 minute read

    -

    A transcription request returned text. Is it ready for meeting notes, subtitles or speaker-based editing? Treat transport, words, speakers and timing as separate acceptance questions. This article uses a FunASR MOSS result and a small standard-library Python checker to show what can be automated, and what still needs listening.

    +

    Before your team shares meeting notes or subtitles, check whether readers can trust the names, decisions and links back to the sound. A successful request is only the start; structure checks and listening answer different questions.

    +

    In the synthetic example below, a 12-second recording has segments ending at second 8. The four-second tail is a reason to inspect the audio, not proof of four seconds of missing speech: it may contain silence.

    +

    A checker cannot hear the recording. Neither two speaker labels nor a timestamp at the end establishes a correct transcript, and recording-local labels do not identify known people.

    FunClip video, subtitle and editing workspace
    An existing FunClip workspace illustrates the downstream application. This is not a screenshot of the synthetic example below.

    Four questions, four kinds of evidence

    @@ -53,18 +56,21 @@

    Move from structural checks to acceptance

    1. Freeze input and configuration. Retain the audio hash, duration, sample rate/channels, model revision, server version, response format and generation limit. Record resampling, channel mixing and trimming. Removing silence requires an offset map if timestamps must locate the original video.
    2. Listen at high-risk positions. Check the start and end, both sides of long silence, speaker changes, interruptions, quiet speech and critical numbers. Sampling can reveal problems; a successful sample is not proof of whole-recording accuracy.
    3. -
    4. Evaluate text and speakers separately. With a human reference transcript, measure CER/WER under consistent punctuation, case and tokenization rules. With human speaker/time annotations, evaluate diarization. The number of distinct output labels is not a diarization acceptance test.
    5. -
    6. Freeze the diarization scoring policy. pyannote.metrics provides speaker metrics. Boundary collars and whether overlapping speech is scored affect results. Compare systems on the same reference and configuration, not detached numbers.
    7. + +
    8. Diagnose incomplete output before tuning. Inspect termination due to generation limits, structural completeness and whether the client consumed the whole response. Raising a token limit may address some truncations, but cannot guarantee recall. A successful request is not grounds to close an unresolved user issue.

    What should travel with the result?

    Follow consent and retention requirements, store only necessary data, and restrict access to audio and speaker annotations. Retain the source-audio identifier, fixed configuration, original output, structural report and human acceptance notes. Link summaries back to transcript passages. Check subtitle readability, positioning and privacy. Replay edited clips instead of only checking interval lengths. Known-person identification needs a separate consent, enrollment and verification design; renaming anonymous cluster labels does not provide it.

    -

    This checker is an entry point into acceptance, not a leaderboard or a substitute for listening and annotation. Start a real workload through the deployment centre, then use the FunASR repository for implementation details or a minimal reproducible issue.

    -

    Sources and reproduction

    + +

    Appendix: scoring policy and sources

    +
    • Evaluate text and speakers separately. With a human reference transcript, measure CER/WER under consistent punctuation, case and tokenization rules. With human speaker/time annotations, evaluate diarization. The number of distinct output labels is not a diarization acceptance test.
    • Freeze the diarization scoring policy. pyannote.metrics provides speaker metrics. Boundary collars and whether overlapping speech is scored affect results. Compare systems on the same reference and configuration, not detached numbers.
    +
    +

    Next, download the read-only structural checker and run the labeled synthetic example first. Use its report to plan listening checks, not to mark a meeting transcript as quality-verified.

    FunASR · Applications and technical explainers
    diff --git a/web-pages/product-site/legacy/en/blog/self-hosted-openai-whisper-api-alternative.html b/web-pages/product-site/legacy/en/blog/self-hosted-openai-whisper-api-alternative.html index b09619456..03a230117 100644 --- a/web-pages/product-site/legacy/en/blog/self-hosted-openai-whisper-api-alternative.html +++ b/web-pages/product-site/legacy/en/blog/self-hosted-openai-whisper-api-alternative.html @@ -1,13 +1,11 @@ -Self-Hosted OpenAI Whisper API Alternative: Integration and Security | FunASR Blog - +Add transcription to your own API | FunASR + - +
    -

    Self-Hosted OpenAI Whisper API Alternative: FunASR Integration and Security

    +

    Add transcription to your own API

    -

    Self-hosting lets a team choose its speech models, capacity and data-handling policy. This guide covers the packaged funasr-server in FunASR 1.4.15 and a basic JSON request to POST /v1/audio/transcriptions. It implements part of the OpenAI transcription interface, not the complete hosted service or every SDK and application contract.

    +

    If your application needs to upload a recording and receive its text, start with that single round trip. A local transcription endpoint lets your team check the request and response before adding real traffic or a public gateway.

    +

    The example uses an authorized audio.wav: a client uploads the file to a local SenseVoice service, then reads the JSON text field. It supplies a callable path, not a fabricated transcript or a speed comparison.

    +

    This article covers the packaged funasr-server in FunASR 1.4.15. It offers part of the OpenAI transcription interface, not every hosted feature or response format; a working URL alone does not complete a migration.

    LayerQuestionNot a substitute
    @@ -95,42 +108,8 @@

    Compatibility of the packaged handler

    ItemCurrent behavior and integration requirement
    RequestMultipart upload. The route declares file, model, language, response_format and the FunASR extension spk. Language and speaker capabilities depend on the selected model.
    Other fields and protocolsprompt, temperature and timestamp_granularities are not declared capabilities of this route. It is not a streaming WebSocket, translation or complete cloud API. An extra field causing no error does not prove it was applied.

    For the packaged service, verify fields against its running /openapi.json over a private or restricted operational path and the pinned packaged handler source. The example-service API schema documents a different implementation, not this packaged handler. Packaged, repository example, vLLM and llama.cpp servers do not share every default, field or response shape.

    -

    Before connecting Open WebUI or another application

    - -

    Compare complete deployments, not just API prices

    -

    Self-hosting has compute, storage, bandwidth, maintenance and monitoring costs. Data control depends on the client, proxy, temporary files, logs and retention policy; neither no-disk handling nor leak-free transcripts are automatic. Review model-weight licenses separately from the toolkit's code license.

    -

    Choose by language, latency, hardware and speaker requirements using model selection, MOSS transcription and diarization and the deployment matrix. This article supplies no new accuracy or performance ranking. For acceptance criteria, see the meeting-audio checklist.

    - -

    Related posts

    - + +

    Before another user connects, work through the service security guide and verify that the gateway cannot be bypassed. Keep the local example private until those deployment checks pass.

    diff --git a/web-pages/product-site/templates/base.html b/web-pages/product-site/templates/base.html index b39231c60..0f68a56d6 100644 --- a/web-pages/product-site/templates/base.html +++ b/web-pages/product-site/templates/base.html @@ -15,7 +15,7 @@ - + } | tojson }}{% endblock %} + {% block extra_head %}{% endblock %} diff --git a/web-pages/product-site/templates/blog.html b/web-pages/product-site/templates/blog.html new file mode 100644 index 000000000..b6feb4007 --- /dev/null +++ b/web-pages/product-site/templates/blog.html @@ -0,0 +1,54 @@ +{% extends "base.html" %} +{% block extra_head %}{% endblock %} +{% block structured_data %}{% endblock %} +{% block content %} +
    +
    + {% if blog.view != 'home' %}{{ 'FunASR 技术博客' if language == 'zh' else 'FunASR Blog' }}{% endif %} +

    {{ blog.heading }}

    +

    {{ blog.description }}

    +
    + + {% if blog.view == 'home' %} +
    + + {{ '本期精选' if language == 'zh' else 'Featured story' }} · {{ blog.lead.category_label }} +

    {{ blog.lead.title }}

    +

    {{ blog.lead.summary }}

    + {{ 'FunClip 的视频、字幕和按片段剪辑工作区' if language == 'zh' else 'The FunClip workspace for video, subtitles and segment editing' }} + {{ 'FunClip 工作区。实际模型与剪辑范围见文中说明。' if language == 'zh' else 'The FunClip workspace. See the story for model and clipping scope.' }} +
    +
    +
    +

    {{ '值得读一读' if language == 'zh' else 'Worth a read' }}

    {{ '全部文章' if language == 'zh' else 'All articles' }}
    +
    + {% for story in blog.selected %} + + {{ story.category_label }}

    {{ story.title }}

    {{ story.summary }}

    +
    + {% endfor %} +
    +
    + {% else %} + {% if blog.view == 'releases' %}{% endif %} +
    + {% for story in blog.stories %} + +
    {{ story.category_label }}{% if story.date %}{% endif %}
    +

    {{ story.title }}

    {% if story.reviewed %}

    {{ story.summary }}

    {% endif %}
    + +
    + {% endfor %} +
    + {% endif %} + +
    +{% endblock %} diff --git a/web-pages/product-site/tests/browser/blog-editorial.spec.ts b/web-pages/product-site/tests/browser/blog-editorial.spec.ts new file mode 100644 index 000000000..0fa4cb13e --- /dev/null +++ b/web-pages/product-site/tests/browser/blog-editorial.spec.ts @@ -0,0 +1,92 @@ +import { expect, test } from '@playwright/test'; + +for (const prefix of ['', 'en/']) { + for (const width of [390, 1440]) { + test(`selected article text contrast ${prefix || 'zh'} at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + for (const slug of ['funclip-v2-2-0-moss-speaker-clipping', 'meeting-transcript-acceptance', + 'fun-asr-nano-transformers', 'self-hosted-openai-whisper-api-alternative', 'funasr-transcribe-long-audio']) { + await page.goto(`/${prefix}blog/${slug}.html`); + const ratios = await page.locator('article p, article li').evaluateAll(nodes => { + const luminance = (color: string) => { + const rgb = color.match(/[\d.]+/g)!.slice(0, 3).map(Number).map(value => { + const c = value / 255; + return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }); + return rgb[0] * 0.2126 + rgb[1] * 0.7152 + rgb[2] * 0.0722; + }; + return nodes.filter(node => node.textContent?.trim()).map(node => { + let parent: Element | null = node; + let background = 'rgb(255, 255, 255)'; + while (parent) { + const value = getComputedStyle(parent).backgroundColor; + if (value !== 'transparent' && value !== 'rgba(0, 0, 0, 0)') { background = value; break; } + parent = parent.parentElement; + } + const foreground = luminance(getComputedStyle(node).color); + const back = luminance(background); + return (Math.max(foreground, back) + 0.05) / (Math.min(foreground, back) + 0.05); + }); + }); + expect(ratios.length).toBeGreaterThan(8); + expect(Math.min(...ratios), slug).toBeGreaterThanOrEqual(4.5); + const linkCues = await page.locator('article p a[href], article li a[href]').evaluateAll(nodes => + nodes.filter(node => node.textContent?.trim()).map(node => getComputedStyle(node).textDecorationLine)); + expect(linkCues.length).toBeGreaterThan(0); + expect(linkCues.every(value => value.includes('underline')), `${slug}: visible inline links`).toBeTruthy(); + if (slug === 'funclip-v2-2-0-moss-speaker-clipping') { + await page.locator('[data-editorial="example"]').scrollIntoViewIfNeeded(); + await page.screenshot({ path: testInfo.outputPath('readable-funclip.png') }); + } + } + }); + + test(`edited blog ${prefix || 'zh'} at ${width}px`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 900 }); + await page.goto(`/${prefix}blog/`); + const home = page.locator('[data-blog-view="home"]'); + await expect(home.locator('[data-blog-story]')).toHaveCount(5); + await expect(home.locator('[data-blog-lead] img')).toBeVisible(); + const layout = await page.evaluate(() => { + const h1 = document.querySelector('h1')!.getBoundingClientRect(); + const image = document.querySelector('[data-blog-lead] img') as HTMLImageElement; + const selected = document.querySelector('[data-blog-selected]')!.getBoundingClientRect(); + return { headingTop: h1.top, headingBottom: h1.bottom, + navBottom: document.querySelector('.site-header')!.getBoundingClientRect().bottom, + overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + imageWidth: image.naturalWidth, selectedTop: selected.top }; + }); + expect(layout.headingTop).toBeGreaterThanOrEqual(layout.navBottom); + expect(layout.headingBottom).toBeLessThan(300); + expect(layout.overflow).toBeLessThanOrEqual(1); + expect(layout.imageWidth).toBeGreaterThan(100); + expect(layout.selectedTop).toBeLessThan(900); + await page.screenshot({ path: testInfo.outputPath('homepage.png'), fullPage: true }); + const lead = home.locator('[data-blog-lead] [data-blog-story]'); + const href = await lead.getAttribute('href'); + await lead.click(); + await expect(page).toHaveURL(new RegExp(href!.replaceAll('.', '\\.'))); + await expect(page.locator('article h1')).toBeVisible(); + for (const category of ['applications', 'selection', 'explanations']) { + await page.goto(`/${prefix}blog/`); + await page.locator(`[data-blog-navigation] a[href="/${prefix}blog/${category}/"]`).click(); + const view = page.locator(`[data-blog-view="${category}"]`); + await expect(view.locator('h1')).toBeVisible(); + expect(await view.locator('[data-blog-story]').count()).toBeGreaterThan(0); + expect(await view.locator(`[data-blog-story]:not([data-blog-category="${category}"])`).count()).toBe(0); + expect(await page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1); + } + await page.locator(`[data-blog-more] a[href="/${prefix}blog/archive/"]`).click(); + const archive = page.locator('[data-blog-view="archive"]'); + await expect(archive.locator('[data-blog-story]')).toHaveCount(35); + await page.screenshot({ path: testInfo.outputPath('archive.png') }); + await archive.locator(`a[href="/${prefix}blog/self-hosted-deepgram-assemblyai-alternative.html"]`).click(); + await expect(page.locator('article h1')).toBeVisible(); + await page.goto(`/${prefix}blog/`); + await page.locator(`[data-blog-more] a[href="/${prefix}blog/releases/"]`).click(); + const releases = page.locator('[data-blog-view="releases"]'); + expect(await releases.locator('[data-blog-story]').count()).toBeGreaterThan(0); + await expect(releases.locator('[data-blog-story]:not([data-blog-category="releases"])')).toHaveCount(0); + }); + } +} diff --git a/web-pages/product-site/tests/browser/http-blog-contracts.spec.ts b/web-pages/product-site/tests/browser/http-blog-contracts.spec.ts index 363234a70..0d6a45650 100644 --- a/web-pages/product-site/tests/browser/http-blog-contracts.spec.ts +++ b/web-pages/product-site/tests/browser/http-blog-contracts.spec.ts @@ -11,6 +11,9 @@ for (const prefix of ['', 'en/']) { test(`HTTP blog contract ${prefix}${slug} at ${width}px`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 900 }); await page.goto(`/${prefix}blog/`); + if (slug === 'self-hosted-deepgram-assemblyai-alternative.html') { + await page.locator(`[data-blog-more] a[href="/${prefix}blog/archive/"]`).click(); + } const route = `/${prefix}blog/${slug}`; await page.locator(`a.post-card[href="${route}"]`).click(); await expect(page).toHaveURL(new RegExp(slug.replaceAll('.', '\\.'))); diff --git a/web-pages/product-site/tests/browser/long-audio-blog.spec.ts b/web-pages/product-site/tests/browser/long-audio-blog.spec.ts index 93ea1dafc..06f1d2ce8 100644 --- a/web-pages/product-site/tests/browser/long-audio-blog.spec.ts +++ b/web-pages/product-site/tests/browser/long-audio-blog.spec.ts @@ -18,7 +18,7 @@ for (const prefix of ['', 'en/']) { overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, headingTop: box('h1').top, navBottom: document.querySelector('nav')!.getBoundingClientRect().bottom, - warningBottom: box('[data-long-audio-contract="window"]').bottom, + warningBottom: box('[data-editorial="boundary"]').bottom, recipeTop: box('[data-long-audio-contract="recipe"]').top, }; }); @@ -26,23 +26,22 @@ for (const prefix of ['', 'en/']) { expect(layout.headingTop).toBeGreaterThanOrEqual(layout.navBottom); expect(layout.warningBottom).toBeLessThanOrEqual(layout.recipeTop); await page.screenshot({ path: testInfo.outputPath('article-top.png') }); - const table = page.locator('[data-long-audio-contract="batching"] table'); - await table.scrollIntoViewIfNeeded(); - const scroll = await table.evaluate((node) => { - const wrapper = node.closest('.table-wrap')!; - const rect = wrapper.getBoundingClientRect(); - wrapper.scrollLeft = wrapper.scrollWidth; - return { left: rect.left, right: rect.right, width: wrapper.clientWidth, - content: wrapper.scrollWidth, scrolled: wrapper.scrollLeft }; - }); - expect(scroll.left).toBeGreaterThanOrEqual(0); - expect(scroll.right).toBeLessThanOrEqual(width); - if (width === 390) { - expect(scroll.content).toBeGreaterThan(scroll.width); - expect(scroll.scrolled).toBeGreaterThan(0); - expect(scroll.scrolled + scroll.width).toBeGreaterThanOrEqual(scroll.content - 1); + const batching = page.locator('[data-long-audio-contract="batching"]'); + await batching.scrollIntoViewIfNeeded(); + await expect(batching.locator('li')).toHaveCount(2); + for (const value of ['max_single_segment_time=30000', 'batch_size_s=300', 'CPU']) { + await expect(batching).toContainText(value); + } + const boxes = await batching.locator('li').evaluateAll(nodes => nodes.map(node => { + const rect = node.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: node.clientWidth, content: node.scrollWidth }; + })); + for (const box of boxes) { + expect(box.left).toBeGreaterThanOrEqual(0); + expect(box.right).toBeLessThanOrEqual(width); + expect(box.content).toBeLessThanOrEqual(box.width + 1); } - await page.screenshot({ path: testInfo.outputPath('batching-table.png') }); + await page.screenshot({ path: testInfo.outputPath('batching-list.png') }); await page.locator(`article a[href="/${prefix}docs/python-api.html"]`).click(); await expect(page.locator('[data-source-link]')).toHaveAttribute( 'href', new RegExp(`/docs/python_api${prefix ? '' : '_zh'}\\.md$`), diff --git a/web-pages/product-site/tests/browser/native-transformers.spec.ts b/web-pages/product-site/tests/browser/native-transformers.spec.ts index bc3d4023b..0efedf89e 100644 --- a/web-pages/product-site/tests/browser/native-transformers.spec.ts +++ b/web-pages/product-site/tests/browser/native-transformers.spec.ts @@ -21,7 +21,12 @@ for (const prefix of ['', 'en/']) { await expect(waveform).toBeVisible(); expect(await waveform.evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth === 1800)).toBeTruthy(); await page.screenshot({ path: testInfo.outputPath('waveform.png') }); - const table = page.locator('[data-native-section="formats"] table'); + const formats = page.locator('[data-native-section="formats"]'); + await expect(formats.locator('li')).toHaveCount(4); + for (const model of ['Fun-ASR-Nano-2512', 'Fun-ASR-Nano-2512-hf', 'Fun-ASR-Nano-2512-vllm', 'GGUF']) { + await expect(formats).toContainText(model); + } + const table = page.locator('[data-native-section="observations"] table'); await table.scrollIntoViewIfNeeded(); const scroll = await table.evaluate((tableNode) => { const node = tableNode.closest('.table-wrap')!; diff --git a/web-pages/product-site/tests/browser/product-site.spec.ts b/web-pages/product-site/tests/browser/product-site.spec.ts index ceac84609..4411bcaf7 100644 --- a/web-pages/product-site/tests/browser/product-site.spec.ts +++ b/web-pages/product-site/tests/browser/product-site.spec.ts @@ -433,31 +433,34 @@ for (const viewport of [ ]) { await page.goto(release.index); await expect( - page.locator(`.launch-feature a[href="${release.article}"]`), + page.locator(`[data-blog-lead] a[href="${release.article}"]`), ).toBeVisible(); - const history = page.locator('.previous-release .post-card'); + await expect(page.locator(`[data-blog-selected] a[href="${release.index}meeting-transcript-acceptance.html"]`)).toBeVisible(); + await page.locator(`[data-blog-more] a[href="${release.index}releases/"]`).click(); + const history = page.locator('[data-blog-view="releases"] [data-blog-story]'); const historySlugs = [ - 'meeting-transcript-acceptance.html', 'funasr-v1-4-14-portable-source-release.html', 'funasr-v1-4-5-pypi-llama-cpp-release.html', 'funasr-v1-4-3-pypi-release.html', 'funasr-v1-4-0-pypi-release.html', ]; - await expect(history).toHaveCount(historySlugs.length); expect(await history.evaluateAll(cards => cards.map(card => card.getAttribute('href')))) - .toEqual(historySlugs.map(slug => `${release.index}${slug}`)); + .toEqual(expect.arrayContaining(historySlugs.map(slug => `${release.index}${slug}`))); + await expect(page.locator('[data-blog-view="releases"] [data-blog-story]:not([data-blog-category="releases"])')).toHaveCount(0); await expect( - page.locator(`.previous-release a[href="${release.previous}"]`), + page.locator(`[data-blog-view="releases"] a[href="${release.previous}"]`), ).toBeVisible(); const indexLayout = await history.evaluateAll((cards) => ({ overflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, rows: new Set(cards.map((card) => Math.round(card.getBoundingClientRect().top))).size, })); expect(indexLayout.overflow).toBeLessThanOrEqual(1); - expect(indexLayout.rows).toBe(Math.ceil(historySlugs.length / (viewport.name === 'mobile' ? 1 : 4))); + expect(indexLayout.rows).toBe(await history.count()); await page.goto(release.article); - await expect(page.locator('h1')).toContainText('FunClip v2.2.0'); + await expect(page.locator('h1')).toHaveText(release.language === 'zh' + ? '把多人录音变成可剪辑的字幕' : 'Turn a conversation into editable subtitles'); + await expect(page.locator('article')).toContainText('FunClip v2.2.0'); await expect(page.getByText('OpenMOSS-Team/MOSS-Transcribe-Diarize', { exact: false }).first()).toBeVisible(); await expect(page.getByText('/v1/audio/transcriptions', { exact: false }).first()).toBeVisible(); await expect(page.locator('img[src="/img/funclip-v2-1-0-interface.jpg"]')).toBeVisible(); diff --git a/web-pages/product-site/tests/test_blog_editorial.py b/web-pages/product-site/tests/test_blog_editorial.py new file mode 100644 index 000000000..a6ed63cc0 --- /dev/null +++ b/web-pages/product-site/tests/test_blog_editorial.py @@ -0,0 +1,105 @@ +"""The blog entry is a curated publication, not the complete article archive.""" + +import copy +import importlib +import json +import sys +from pathlib import Path + +import pytest +from bs4 import BeautifulSoup + +SITE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SITE)) +from build import build + + +@pytest.fixture(scope="module") +def output(tmp_path_factory): + root = tmp_path_factory.mktemp("blog-editorial") + build(root) + return root + + +@pytest.mark.parametrize("prefix", ("", "en/")) +def test_home_is_an_edited_selection(output, prefix): + soup = BeautifulSoup((output / prefix / "blog/index.html").read_text(), "html.parser") + main = soup.select_one("main[data-blog-view='home']") or soup.select_one("main [data-blog-view='home']") + assert main is not None, "Render a curated blog homepage" + assert len(main.select("a[data-blog-story]")) == 5 + assert len(main.select("[data-blog-lead]")) == 1 + assert len(main.select("[data-blog-selected] a[data-blog-story]")) == 4 + assert len({a["href"] for a in main.select("a[data-blog-story]")}) == 5 + assert main.select_one("h1").get_text(strip=True).startswith("FunASR") + assert not main.select(".previous-release, .launch-feature") + image = main.select_one("[data-blog-lead] img") + assert image and image.get("alt") + assert (output / image["src"].lstrip("/")).is_file() + assert all(a.get("data-blog-category") != "releases" for a in main.select("a[data-blog-story]")) + + +def test_catalogue_covers_every_legacy_article_and_no_paused_draft(): + blog = importlib.import_module("blog") + data = blog.load_blog(SITE) + slugs = {entry["slug"] for entry in data["articles"]} + for prefix in ("", "en/"): + actual = {p.stem for p in (SITE / "legacy" / prefix / "blog").glob("*.html") if p.name != "index.html"} + assert slugs == actual + assert "recoverable-batch-transcription" not in slugs + selected = [data["lead"], *data["selected"]] + assert len(selected) == len(set(selected)) == 5 + assert all(next(e for e in data["articles"] if e["slug"] == s)["reviewed"] for s in selected) + + +@pytest.mark.parametrize("mutation", ("duplicate", "unsafe", "translation", "selection", "category", "missing", "image", "review")) +def test_invalid_catalogue_is_rejected(mutation): + blog = importlib.import_module("blog") + data = json.loads((SITE / "data/blog.json").read_text()) + if mutation == "duplicate": + data["articles"].append(copy.deepcopy(data["articles"][0])) + elif mutation == "unsafe": + data["articles"][0]["slug"] = "../../secrets" + elif mutation == "translation": + del data["articles"][0]["en"] + elif mutation == "selection": + data["selected"][0] = data["lead"] + elif mutation == "category": + data["articles"][0]["category"] = "anything" + elif mutation == "image": + data["lead_image"] = "/img/not-a-real-image.png" + elif mutation == "review": + next(e for e in data["articles"] if e["slug"] == data["lead"])["reviewed"] = False + else: + data["articles"].pop() + with pytest.raises(ValueError): + blog.validate_blog(data, SITE) + + +@pytest.mark.parametrize("prefix", ("", "en/")) +def test_archive_preserves_all_routes_and_category_membership(output, prefix): + data = importlib.import_module("blog").load_blog(SITE) + archive = BeautifulSoup((output / prefix / "blog/archive/index.html").read_text(), "html.parser") + expected = {f"/{prefix}blog/{e['slug']}.html" for e in data["articles"]} + assert {a["href"] for a in archive.select("a[data-blog-story]")} == expected + for href in expected: + assert (output / href.lstrip("/")).is_file() + for category in ("applications", "selection", "explanations", "releases"): + soup = BeautifulSoup((output / prefix / "blog" / category / "index.html").read_text(), "html.parser") + expected_category = {f"/{prefix}blog/{e['slug']}.html" for e in data["articles"] + if e["category"] == category and (e["reviewed"] or category == "releases")} + assert {a["href"] for a in soup.select("a[data-blog-story]")} == expected_category + assert expected_category + + +@pytest.mark.parametrize("prefix", ("", "en/")) +def test_blog_routes_have_metadata_and_crawlable_navigation(output, prefix): + for view in ("", "applications/", "selection/", "explanations/", "archive/", "releases/"): + route = f"/{prefix}blog/{view}" + soup = BeautifulSoup((output / route.lstrip("/") / "index.html").read_text(), "html.parser") + assert soup.find("link", rel="canonical")["href"] == "https://www.funasr.com" + route + other = "" if prefix else "en/" + assert soup.select_one(f'link[rel="alternate"][href="https://www.funasr.com/{other}blog/{view}"]') + assert len(soup.select("h1")) == 1 + assert soup.select_one('script[type="application/ld+json"]') + for a in soup.select("[data-blog-navigation] a, [data-blog-more] a"): + assert (output / a["href"].lstrip("/") / "index.html").is_file() diff --git a/web-pages/product-site/tests/test_blog_editorial_articles.py b/web-pages/product-site/tests/test_blog_editorial_articles.py new file mode 100644 index 000000000..69d47a703 --- /dev/null +++ b/web-pages/product-site/tests/test_blog_editorial_articles.py @@ -0,0 +1,141 @@ +"""Selected articles answer one reader question without changing tested recipes.""" + +import hashlib +import json +from pathlib import Path +import re +import sys + +from bs4 import BeautifulSoup +import pytest + + +SITE = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SITE)) +from build import build + +ARTICLES = { + "funclip-v2-2-0-moss-speaker-clipping": { + "titles": ("把多人录音变成可剪辑的字幕", "Turn a conversation into editable subtitles"), + "published": "2026-08-31", + "codes": ["329cac5e850c65af46316133408a35e54922a49f1bff99a17606d8278187741c", "bba936173d3caebcb982dced324785a3d45748786a1f8c0bd07db6b9515eaa9b", "9f793e6ff863cfa476a93f8324efa30d5f0a993bbec9140839a1cf0c33ca6674", "6fc5f4fd13841d22e0f30e76c0eba5a69240530895baaf628a8dc8ea7d53bd4a"], + }, + "fun-asr-nano-transformers": { + "titles": ("接入 Transformers,先选对权重", "Choose the right checkpoint for Transformers"), + "published": "2026-09-09", "codes": [], + }, + "funasr-transcribe-long-audio": { + "titles": ("长录音为什么会漏掉结尾?", "Why can a long transcript miss the ending?"), + "published": "2026-06-17", + "codes": ["3a5fbb7f534c11edd7693e1e0a70cf8b66b35cf737f7669d2d6f5d19953f0745"], + }, + "self-hosted-openai-whisper-api-alternative": { + "titles": ("把语音转写接进自己的 API", "Add transcription to your own API"), + "published": "2026-06-18", + "codes": ["ffaeacf00f8f84adbc0e2d5c3291412987427592ff9f5518e82cd6e3955062e4", "f87d523ad71de6c2d5f39bb7a549031c98be8feed3663e27f3419cd97179e747", "006d3d5b258d15e2c1be5822b7268b94923d584a730f0fcdd647b9beb4372d05", "4d4613a7fa2355b3134f7d4e932bb69dfac48a1fbac2485da79815e9064cef02"], + }, + "meeting-transcript-acceptance": { + "titles": ("会议转写,怎样才算做好了?", "When is a meeting transcript ready to use?"), + "published": "2026-09-09", + "codes": ["2bfbe86981951ad5f7518cb370f77ffbf8042ae264b94e6300113866dc43671d", "c5c350081e1c145ce5701c69388d57eba97b6ec92056c2bd897dc4ccc88a33a2"], + }, +} + + +@pytest.fixture(scope="module", params=["source", "built"]) +def tree(request, tmp_path_factory): + if request.param == "source": + return SITE / "legacy" + output = tmp_path_factory.mktemp("editorial-articles") + build(output) + return output + + +@pytest.fixture(params=[(slug, prefix) for slug in ARTICLES for prefix in ("", "en/")], + ids=[slug + ("-en" if prefix else "-zh") for slug in ARTICLES for prefix in ("", "en/")]) +def article(request, tree): + slug, prefix = request.param + soup = BeautifulSoup((tree / prefix / "blog" / (slug + ".html")).read_text(), "html.parser") + return slug, prefix, soup + + +def visible(node): + assert node is not None, "Missing editorial content, not just metadata" + for parent in (node, *node.parents): + assert not parent.has_attr("hidden") and parent.get("aria-hidden") != "true" + assert not re.search(r"display\s*:\s*none|visibility\s*:\s*hidden", parent.get("style", "")) + assert parent.name != "details" or parent.has_attr("open"), "Core conclusion must not require opening an appendix" + text = " ".join(node.get_text(" ", strip=True).split()) + assert text + return text + + +def test_title_metadata_and_existing_routes_stay_in_sync(article): + slug, prefix, soup = article + expected = ARTICLES[slug]["titles"][bool(prefix)] + assert visible(soup.select_one("article h1")) == expected + assert soup.select_one('meta[property="og:title"]')["content"] == expected + assert soup.title.get_text().startswith(expected) + metadata = json.loads(soup.select_one('script[type="application/ld+json"]').get_text()) + assert metadata["headline"] == expected + assert metadata["datePublished"] == ARTICLES[slug]["published"] + assert metadata["dateModified"] == "2026-09-09" + assert soup.select_one('link[rel="canonical"]')["href"] == f"https://www.funasr.com/{prefix}blog/{slug}.html" + peer = "" if prefix else "en/" + assert soup.select_one(f'link[rel="alternate"][href="https://www.funasr.com/{peer}blog/{slug}.html"]') + desc = soup.select_one('meta[name="description"]')["content"] + assert 20 <= len(desc) <= (220 if prefix else 110) + + +def test_opening_serves_a_reader_and_example_before_technical_inventory(article): + _, prefix, soup = article + body = soup.select_one("article") + opening = body.select_one('[data-editorial="opening"]') + example = body.select_one('[data-editorial="example"]') + text = visible(opening) + assert len(text) <= (440 if prefix else 180) + assert not opening.select("code, table, pre"), "Do not turn the opening back into a parameter list" + assert re.search(r"you|your|developer|team|reader|你|团队|开发者|适合", text, re.I) + assert len(visible(example)) >= (65 if prefix else 30) + order = {id(n): i for i, n in enumerate(body.descendants)} + first_h2 = body.select_one("h2") + assert order[id(opening)] < order[id(example)] < order[id(first_h2)] + assert opening.find_parent(attrs={"data-editorial": "appendix"}) is None + + +def test_constraints_remain_near_conclusion_and_one_next_step(article): + _, _, soup = article + boundary = soup.select_one('article [data-editorial="boundary"]') + assert re.search(r"not|cannot|only|doesn't|不是|不等于|不能|只|不保证", visible(boundary), re.I) + assert boundary.find_parent(attrs={"data-editorial": "appendix"}) is None + next_step = soup.select_one('article [data-editorial="next-step"]') + assert len(next_step.select("a[href]")) == 1 + assert len(visible(next_step)) >= 25 + assert not next_step.find_next("h2"), "End with one action, not another knowledge directory" + assert len(soup.select("article .post-list li")) <= 3 + + +def test_existing_code_and_link_anchors_are_not_rewritten(article): + slug, _, soup = article + # Captured from the exact pre-edit source, identical between languages. + blocks = [hashlib.sha256(p.get_text().encode()).hexdigest() for p in soup.select("article pre")] + assert blocks == ARTICLES[slug]["codes"] + if slug == "self-hosted-openai-whisper-api-alternative": + for anchor in ("security-boundary", "api-contract"): + assert len(soup.select(f"[id={anchor}]")) == 1 + + +def test_media_and_historical_commands_have_honest_context(article): + slug, prefix, soup = article + for image in soup.select("article img"): + figure = image.find_parent("figure") + assert figure is not None and figure.select_one("figcaption"), "Keep the real asset's provenance next to it" + assert len(visible(figure.select_one("figcaption"))) > 25 + if slug == "funclip-v2-2-0-moss-speaker-clipping": + block = next(p for p in soup.select("article pre") if "pip install -U vllm" in p.get_text()) + appendix = block.find_parent(attrs={"data-editorial": "appendix"}) + assert appendix is not None + text = appendix.get_text(" ", strip=True) + assert re.search(r"historical|历史", text, re.I) + assert re.search(r"not.*(?:install|compatib)|不是.*安装|不.*兼容", text, re.I) + assert soup.select_one(f'article a[href="/{prefix}deploy/moss-transcribe-diarize.html"]') diff --git a/web-pages/product-site/tests/test_http_blog_contracts.py b/web-pages/product-site/tests/test_http_blog_contracts.py index 7ce34bc0f..8167766a4 100644 --- a/web-pages/product-site/tests/test_http_blog_contracts.py +++ b/web-pages/product-site/tests/test_http_blog_contracts.py @@ -175,6 +175,10 @@ def test_migration_claims_are_bounded_in_article_metadata_and_index(site_tree, p for text in prose_and_metadata(soup): assert_no_blanket_claims(text) index = read_page(site_tree, prefix, "index.html") + if site_tree != SITE / "legacy" and slug == "self-hosted-deepgram-assemblyai-alternative.html": + assert index.select_one(f'[data-blog-more] a[href="/{prefix}blog/archive/"]') + assert not index.select(f'a.post-card[href="/{prefix}blog/{slug}"]') + index = read_page(site_tree, prefix, "archive/index.html") cards = index.select(f'a.post-card[href="/{prefix}blog/{slug}"]') assert len(cards) == 1 assert_no_blanket_claims(visible_text(cards[0])) diff --git a/web-pages/product-site/tests/test_legacy.py b/web-pages/product-site/tests/test_legacy.py index 7274fe4ba..ccefd6c2c 100644 --- a/web-pages/product-site/tests/test_legacy.py +++ b/web-pages/product-site/tests/test_legacy.py @@ -462,7 +462,7 @@ def test_funclip_v220_moss_release_pages_are_bilingual_indexed_and_verifiable(): assert image metadata = json.loads(soup.select_one('script[type="application/ld+json"]').string) assert metadata['datePublished'] == '2026-08-31' - assert metadata['dateModified'] == '2026-08-31' + assert metadata['dateModified'] == '2026-09-09' zh_index = (LEGACY / 'blog' / 'index.html').read_text(encoding='utf-8') en_index = (LEGACY / 'en' / 'blog' / 'index.html').read_text(encoding='utf-8') diff --git a/web-pages/product-site/tests/test_output.py b/web-pages/product-site/tests/test_output.py index ed4f3be0b..b6e415cb8 100644 --- a/web-pages/product-site/tests/test_output.py +++ b/web-pages/product-site/tests/test_output.py @@ -588,7 +588,12 @@ def test_subtitle_edit_blog_is_bilingual_and_evidence_backed( def test_blog_indexes_surface_subtitle_edit_release(built_site, relative, href): soup = read_soup(built_site / relative) - assert soup.select_one(f'a[href="{href}"]') + archive_route = '/' + str(Path(relative).parent) + '/archive/' + assert soup.select_one(f'[data-blog-more] a[href="{archive_route}"]') + assert not soup.select_one(f'[data-blog-story][href="{href}"]') + archive = read_soup(built_site / archive_route.lstrip('/') / 'index.html') + assert archive.select_one(f'a[href="{href}"]') + assert read_soup(built_site / href.lstrip('/')).select_one('article h1') def test_complete_build_passes_output_validation(built_site): @@ -749,12 +754,17 @@ def test_blog_index_features_latest_release_and_preserves_history( built_site, relative, feature_href, history_href ): soup = read_soup(built_site / relative) - feature = soup.select_one(f'.launch-feature a[href="{feature_href}"]') + feature = soup.select_one(f'[data-blog-lead] a[href="{feature_href}"]') assert feature - assert 'FunClip v2.2.0' in feature.get_text(' ', strip=True) - history = soup.select_one('.previous-release') - assert history + assert feature.select_one('h2').get_text(' ', strip=True) + assert 'FunClip v2.2.0' in read_soup( + built_site / feature_href.lstrip('/') + ).get_text(' ', strip=True) + history_route = '/' + str(Path(relative).parent) + '/releases/' + assert soup.select_one(f'[data-blog-more] a[href="{history_route}"]') + assert not soup.select_one(f'[data-blog-story][href="{history_href}"]') + history = read_soup(built_site / history_route.lstrip('/') / 'index.html') assert history.select_one(f'a[href="{history_href}"]') history_text = history.get_text(' ', strip=True) assert 'v1.4.14' in history_text