import importlib.util from pathlib import Path import sys from types import SimpleNamespace import pytest import torch REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) PROCESSOR_PATH = ( REPO_ROOT / "nodes" / "fish_audio_s2" / "fish_audio_s2_processor.py" ) SPEC = importlib.util.spec_from_file_location("fish_audio_s2_processor_test_module", PROCESSOR_PATH) PROCESSOR_MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(PROCESSOR_MODULE) class FakeCharacterParser: def set_available_characters(self, _characters): pass def set_character_language_default(self, _character, _language): pass def reset_session_cache(self): pass def parse_text_segments(self, text, engine_type=None): characters = ["alice", "bob", "rick"] if text == "all" else ["bob", "rick"] return [ SimpleNamespace(character=character, text=character.title(), parameters={}, language="en") for character in characters ] class CapturingAdapter: def __init__(self): self.calls = [] def update_config(self, _config): pass def generate_dialogue(self, turns, voice_refs, **_kwargs): self.calls.append((list(turns), list(voice_refs))) return torch.zeros(1, 16) @pytest.mark.unit def test_late_global_character_uses_local_reference_order(monkeypatch): parser = FakeCharacterParser() def parse_subtitle_three(text, engine_type=None): if text == "subtitle3": return [ SimpleNamespace(character="male_01", text="Bob line", parameters={}, language="en"), SimpleNamespace(character="narrator", text="Narrator line", parameters={}, language="en"), ] return [ SimpleNamespace(character="narrator", text="Narrator", parameters={}, language="en"), SimpleNamespace(character="female_01", text="Alice", parameters={}, language="en"), SimpleNamespace(character="clint_eastwood cc3 (enhanced2)", text="Cowboy", parameters={}, language="en"), SimpleNamespace(character="male_01", text="Bob", parameters={}, language="en"), ] parser.parse_text_segments = parse_subtitle_three monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {"male_01": ("male_01.wav", "Bob reference")}, ) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [ {"character_name": "Ana"}, {"character_name": "Tony"}, ], "multi_speaker_mode": "Native Multi-Speaker", "language_prompting": "Off", }, ) reference_order = processor.get_character_order("all") processor.process_text( "subtitle3", {"narrator": {"character_name": "Joe"}}, seed=1, reference_order=reference_order, show_text_logging=False, ) turns, used_references = adapter.calls[0] assert turns == [(0, "Bob line"), (1, "Narrator line")] assert used_references == [ {"audio_path": "male_01.wav", "reference_text": "Bob reference"}, {"character_name": "Joe"}, ] @pytest.mark.unit def test_srt_uses_global_reference_lookup_with_local_speaker_ids(monkeypatch): parser = FakeCharacterParser() monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr(PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {}) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() references = [{"id": "bob"}, {"id": "rick"}] processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, {"speaker_references": references}, ) reference_order = processor.get_character_order("all") processor.process_text("subtitle", {}, seed=1, reference_order=reference_order) turns, used_references = adapter.calls[0] assert turns == [(0, "Bob"), (1, "Rick")] assert [reference["id"] for reference in used_references] == ["bob", "rick"] @pytest.mark.unit def test_custom_switching_generates_each_segment_as_local_speaker_zero(monkeypatch): parser = FakeCharacterParser() monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr(PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {}) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() references = [{"id": "bob"}, {"id": "rick"}] processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": references, "multi_speaker_mode": "Custom Character Switching", }, ) reference_order = processor.get_character_order("all") records = processor.process_text( "subtitle", {}, seed=1, reference_order=reference_order ) assert [call[0] for call in adapter.calls] == [[(0, "Bob")], [(0, "Rick")]] assert [[ref["id"] for ref in call[1]] for call in adapter.calls] == [["bob"], ["rick"]] assert [record["text"] for record in records] == ["Bob", "Rick"] @pytest.mark.unit def test_language_prompting_prepends_shared_display_name(monkeypatch): parser = FakeCharacterParser() def parse_with_language(_text, engine_type=None): assert engine_type == "fish_audio_s2" return [ SimpleNamespace(character="alice", text="Hallo", parameters={}, language="de", explicit_language=False), ] parser.parse_text_segments = parse_with_language monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr(PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {}) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [{"id": "alice"}], "multi_speaker_mode": "Custom Character Switching", "language_prompting": "Auto Inline Tag", }, ) processor.process_text("subtitle", {}, seed=1, reference_order=["alice"], show_text_logging=False) turns, _used_references = adapter.calls[0] assert turns == [(0, " Hallo")] @pytest.mark.unit def test_explicit_english_language_prompt_is_preserved(monkeypatch): parser = FakeCharacterParser() def parse_with_explicit_english(_text, engine_type=None): assert engine_type == "fish_audio_s2" return [ SimpleNamespace(character="alice", text="Hello", parameters={}, language="en", explicit_language=True), ] parser.parse_text_segments = parse_with_explicit_english monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr(PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {}) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [{"id": "alice"}], "multi_speaker_mode": "Custom Character Switching", "language_prompting": "Auto Inline Tag", }, ) processor.process_text("subtitle", {}, seed=1, reference_order=["alice"], show_text_logging=False) turns, _used_references = adapter.calls[0] assert turns == [(0, " Hello")] @pytest.mark.unit def test_custom_override_logs_connected_voice_and_does_not_inherit_alias_language(monkeypatch, capsys): parser = FakeCharacterParser() def parse_bob(_text, engine_type=None): assert engine_type == "fish_audio_s2" return [ SimpleNamespace(character="bob", text="Bonjour", parameters={}, language="fr"), ] parser.parse_text_segments = parse_bob monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {"bob": ("bob.wav", "Bob ref")}, ) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [{"id": "tony", "character_name": "Tony"}], "multi_speaker_mode": "Custom Character Switching", "language_prompting": "Auto Inline Tag", }, ) processor.process_text( "subtitle", {"narrator": {"audio_path": "joe.wav", "character_name": "Joe"}}, seed=1, reference_order=["narrator", "bob"], ) output = capsys.readouterr().out assert "Speaker 2 input overrides ['bob'] alias" in output assert "[Tony] Bonjour" in output assert "" not in output @pytest.mark.unit def test_single_character_native_call_uses_narrator_as_speaker_one_when_no_global_narrator_exists(monkeypatch): parser = FakeCharacterParser() def parse_single_bob(_text, engine_type=None): assert engine_type == "fish_audio_s2" return [ SimpleNamespace(character="bob", text="Bonjour", parameters={}, language="fr", explicit_language=False), ] parser.parse_text_segments = parse_single_bob monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {"bob": ("bob.wav", "Bob ref")}, ) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [{"id": "speaker1_female"}], "multi_speaker_mode": "Native Multi-Speaker", "language_prompting": "Off", }, ) processor.process_text( "subtitle", {"narrator": {"id": "narrator_voice"}}, seed=1, reference_order=["bob"], show_text_logging=False, ) turns, used_references = adapter.calls[0] assert turns == [(0, "Bonjour")] assert used_references == [{"id": "narrator_voice"}] @pytest.mark.unit def test_first_tagged_character_uses_speaker2_when_global_narrator_exists(monkeypatch): parser = FakeCharacterParser() def parse_single_bob(_text, engine_type=None): assert engine_type == "fish_audio_s2" return [ SimpleNamespace(character="bob", text="Bonjour", parameters={}, language="fr", explicit_language=False), ] parser.parse_text_segments = parse_single_bob monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {"bob": ("bob.wav", "Bob ref")}, ) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [{"id": "speaker2_voice"}], "multi_speaker_mode": "Native Multi-Speaker", "language_prompting": "Off", }, ) processor.process_text( "subtitle", {"narrator": {"id": "narrator_voice"}}, seed=1, reference_order=["narrator", "bob"], show_text_logging=False, ) turns, used_references = adapter.calls[0] assert turns == [(0, "Bonjour")] assert used_references == [{"id": "speaker2_voice"}] @pytest.mark.unit def test_second_character_uses_speaker2_override_when_narrator_not_reserved(monkeypatch): parser = FakeCharacterParser() def parse_bob_then_rick(_text, engine_type=None): assert engine_type == "fish_audio_s2" return [ SimpleNamespace(character="bob", text="Bonjour", parameters={}, language="fr", explicit_language=False), SimpleNamespace(character="rick", text="Salut", parameters={}, language="fr", explicit_language=False), ] parser.parse_text_segments = parse_bob_then_rick monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {"bob": ("bob.wav", "Bob ref"), "rick": ("rick.wav", "Rick ref")}, ) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [{"id": "speaker2_voice"}], "multi_speaker_mode": "Native Multi-Speaker", "language_prompting": "Off", }, ) processor.process_text( "subtitle", {"narrator": {"id": "narrator_voice"}}, seed=1, reference_order=["bob", "rick"], show_text_logging=False, ) turns, used_references = adapter.calls[0] assert turns == [(0, "Bonjour"), (1, "Salut")] assert used_references == [{"id": "narrator_voice"}, {"id": "speaker2_voice"}] @pytest.mark.unit def test_native_override_uses_effective_speaker_default_language_for_auto_inline_tag(monkeypatch): parser = FakeCharacterParser() def parse_single_bob(_text, engine_type=None): assert engine_type == "fish_audio_s2" return [ SimpleNamespace( character="male_01", original_character="Bob", text="Bonjour", parameters={}, language="fr", explicit_language=False, ), ] parser.parse_text_segments = parse_single_bob monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {"male_01": ("bob.wav", "Bob ref")}, ) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_default_language", lambda character: {"anna": "de", "male_01": "fr"}.get(character.lower()), ) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [{"character_name": "Anna"}], "multi_speaker_mode": "Native Multi-Speaker", "language_prompting": "Auto Inline Tag", }, ) processor.process_text( "subtitle", {"narrator": {"character_name": "Joe"}}, seed=1, reference_order=["narrator", "male_01"], show_text_logging=False, ) turns, used_references = adapter.calls[0] assert turns == [(0, " Bonjour")] assert used_references == [{"character_name": "Anna"}] @pytest.mark.unit def test_native_override_with_unmapped_connected_voice_does_not_fall_back_to_alias_language(monkeypatch): parser = FakeCharacterParser() def parse_single_bob(_text, engine_type=None): assert engine_type == "fish_audio_s2" return [ SimpleNamespace( character="male_01", original_character="Bob", text="Bonjour", parameters={}, language="fr", explicit_language=False, ), ] parser.parse_text_segments = parse_single_bob monkeypatch.setattr(PROCESSOR_MODULE, "character_parser", parser) monkeypatch.setattr(PROCESSOR_MODULE, "get_available_characters", lambda: []) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_mapping", lambda *_args, **_kwargs: {"male_01": ("bob.wav", "Bob ref")}, ) monkeypatch.setattr( PROCESSOR_MODULE, "get_character_default_language", lambda character: None, ) monkeypatch.setattr( PROCESSOR_MODULE, "model_management", SimpleNamespace(interrupt_processing=False), ) monkeypatch.setattr( PROCESSOR_MODULE, "voice_discovery", SimpleNamespace( get_character_aliases=lambda: {}, get_character_language_defaults=lambda: {}, ), ) adapter = CapturingAdapter() processor = PROCESSOR_MODULE.FishAudioS2Processor( adapter, { "speaker_references": [{"character_name": "Tony"}], "multi_speaker_mode": "Native Multi-Speaker", "language_prompting": "Auto Inline Tag", }, ) processor.process_text( "subtitle", {"narrator": {"character_name": "Joe"}}, seed=1, reference_order=["narrator", "male_01"], show_text_logging=False, ) turns, used_references = adapter.calls[0] assert turns == [(0, "Bonjour")] assert used_references == [{"character_name": "Tony"}]