Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/en/finetune.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ You need to convert your dataset into the above format and place it under `data`
!!! info
The `.lab` annotation file only needs to contain the transcription of the audio, with no special formatting required. For example, if `hi.mp3` says "Hello, goodbye," then the `hi.lab` file would contain a single line of text: "Hello, goodbye."

If your audio does not have transcripts yet, you can optionally create `.lab`
files with FunASR and the default SenseVoiceSmall model:

```bash
pip install "funasr>=1.3.27,<2"
python tools/annotate_funasr.py data --device cuda:0 --language auto
```

Use `--device cpu` on a machine without CUDA. The command searches `.wav`,
`.mp3`, and `.flac` files recursively, skips existing `.lab` files by default,
and continues when an individual audio file fails. Run it with `--dry-run` to
preview the work or `--overwrite` to replace existing labels. `--model`,
`--language`, and `--no-itn` can be used to select another FunASR checkpoint or
change its transcription options.

The default SenseVoiceSmall checkpoint supports Mandarin, Cantonese, English,
Japanese, and Korean. Review generated labels before training, especially for
long recordings or audio outside those languages. The checkpoint is distributed
under the [FunASR Model License](https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE).

!!! warning
It's recommended to apply loudness normalization to the dataset. You can use [fish-audio-preprocess](https://github.com/fishaudio/audio-preprocess) to do this.

Expand Down
18 changes: 18 additions & 0 deletions docs/zh/finetune.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,24 @@
!!! info
标注文件 `.lab` 仅需包含音频的转写文本,无需遵循特殊格式要求。例如,如果 `hi.mp3` 中的内容是“你好,再见。”,那么 `hi.lab` 文件中只需包含一行文本:“你好,再见”。

如果音频还没有转写文本,可以选择使用 FunASR 和默认的
SenseVoiceSmall 模型生成 `.lab` 文件:

```bash
pip install "funasr>=1.3.27,<2"
python tools/annotate_funasr.py data --device cuda:0 --language auto
```

没有 CUDA 时可改用 `--device cpu`。该命令会递归查找 `.wav`、`.mp3` 和
`.flac` 文件,默认跳过已有的 `.lab` 文件,并在单个音频失败时继续处理。
使用 `--dry-run` 可以先预览待处理文件,使用 `--overwrite` 可以覆盖已有标注。
还可以通过 `--model`、`--language` 和 `--no-itn` 选择其他 FunASR 模型或
调整转写选项。

默认 SenseVoiceSmall 模型支持普通话、粤语、英语、日语和韩语。训练前请人工
检查自动生成的标注,特别是长音频或上述语种之外的音频。该模型使用
[FunASR 模型许可协议](https://github.com/modelscope/FunASR/blob/main/MODEL_LICENSE)。

!!! warning
建议先对数据集进行响度匹配, 你可以使用 [fish-audio-preprocess](https://github.com/fishaudio/audio-preprocess) 来完成这一步骤.
```bash
Expand Down
160 changes: 160 additions & 0 deletions tests/test_annotate_funasr.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import importlib.util
import tempfile
import unittest
from pathlib import Path

MODULE_PATH = Path(__file__).resolve().parents[1] / "tools" / "annotate_funasr.py"


def load_module():
if not MODULE_PATH.is_file():
raise AssertionError("FunASR annotation tool is not implemented")
spec = importlib.util.spec_from_file_location("annotate_funasr", MODULE_PATH)
module = importlib.util.module_from_spec(spec)
if spec.loader is None:
raise AssertionError("Unable to load FunASR annotation tool")
spec.loader.exec_module(module)
return module


def touch(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"audio")
return path


class AnnotateFunASRTests(unittest.TestCase):
def setUp(self):
self.module = load_module()
self.temporary_directory = tempfile.TemporaryDirectory()
self.root = Path(self.temporary_directory.name)

def tearDown(self):
self.temporary_directory.cleanup()

def test_discovers_supported_audio_recursively_in_stable_order(self):
expected = [
touch(self.root / "a" / "first.WAV"),
touch(self.root / "a" / "second.mp3"),
touch(self.root / "b.flac"),
]
touch(self.root / "ignored.ogg")
touch(self.root / "note.txt")

self.assertEqual(self.module.discover_audio_files(self.root), expected)

def test_skips_existing_labels_without_loading_the_transcriber(self):
audio = touch(self.root / "sample.wav")
audio.with_suffix(".lab").write_text("curated text", encoding="utf-8")

def must_not_run(_path):
raise AssertionError("transcriber should stay lazy")

stats = self.module.annotate_dataset(self.root, must_not_run)

self.assertEqual(stats, self.module.AnnotationStats(discovered=1, skipped=1))
self.assertEqual(
audio.with_suffix(".lab").read_text(encoding="utf-8"), "curated text"
)

def test_dry_run_reports_work_without_loading_or_writing(self):
audio = touch(self.root / "sample.mp3")

def must_not_run(_path):
raise AssertionError("transcriber should stay lazy")

stats = self.module.annotate_dataset(self.root, must_not_run, dry_run=True)

self.assertEqual(stats, self.module.AnnotationStats(discovered=1, planned=1))
self.assertFalse(audio.with_suffix(".lab").exists())

def test_writes_utf8_transcript_and_preserves_a_terminal_newline(self):
audio = touch(self.root / "speaker" / "sample.flac")

stats = self.module.annotate_dataset(
self.root, lambda _path: " 你好,世界。 "
)

self.assertEqual(stats, self.module.AnnotationStats(discovered=1, processed=1))
self.assertEqual(
audio.with_suffix(".lab").read_bytes(), "你好,世界。\n".encode()
)

def test_normalizes_transcript_whitespace_to_one_line(self):
audio = touch(self.root / "sample.wav")

stats = self.module.annotate_dataset(
self.root, lambda _path: " first line\n\nsecond\tline "
)

self.assertEqual(stats, self.module.AnnotationStats(discovered=1, processed=1))
self.assertEqual(
audio.with_suffix(".lab").read_text(encoding="utf-8"),
"first line second line\n",
)

def test_overwrite_replaces_an_existing_label(self):
audio = touch(self.root / "sample.wav")
label = audio.with_suffix(".lab")
label.write_text("old\n", encoding="utf-8")

stats = self.module.annotate_dataset(
self.root, lambda _path: "new", overwrite=True
)

self.assertEqual(stats, self.module.AnnotationStats(discovered=1, processed=1))
self.assertEqual(label.read_text(encoding="utf-8"), "new\n")

def test_one_failed_file_does_not_stop_the_remaining_dataset(self):
first = touch(self.root / "a.wav")
second = touch(self.root / "b.wav")
errors = []

def transcribe(path):
if path == first:
raise RuntimeError("damaged audio")
return "usable"

stats = self.module.annotate_dataset(
self.root,
transcribe,
on_error=lambda path, error: errors.append((path, str(error))),
)

self.assertEqual(
stats,
self.module.AnnotationStats(discovered=2, processed=1, failed=1),
)
self.assertEqual(errors, [(first, "damaged audio")])
self.assertFalse(first.with_suffix(".lab").exists())
self.assertEqual(
second.with_suffix(".lab").read_text(encoding="utf-8"), "usable\n"
)

def test_rejects_malformed_or_empty_funasr_results(self):
results = [
None,
[],
[{}],
[{"text": None}],
[{"text": ""}],
[{"text": "<|Speech|>"}],
]
for result in results:
with self.subTest(result=result):
with self.assertRaisesRegex(ValueError, "transcription text"):
self.module.extract_transcript(
result, lambda text: text.replace("<|Speech|>", "")
)

def test_extracts_and_postprocesses_funasr_text(self):
text = self.module.extract_transcript(
[{"text": "<|zh|><|NEUTRAL|><|Speech|>欢迎使用 Fish Speech"}],
lambda value: value.split(">", 3)[-1],
)

self.assertEqual(text, "欢迎使用 Fish Speech")


if __name__ == "__main__":
unittest.main()
Loading