File size: 12,906 Bytes
e43fe76
 
 
 
 
7b3fa89
e43fe76
 
 
 
 
 
21c2288
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bbcd177
 
 
 
21c2288
786959b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cce9569
 
 
 
6c2ab00
cce9569
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c2ab00
 
 
cce9569
6c2ab00
cce9569
 
 
 
 
7b3fa89
 
21c2288
7b3fa89
 
 
 
 
 
 
 
 
 
 
 
 
21c2288
 
 
 
 
7b3fa89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6c2ab00
7b3fa89
 
 
 
 
 
 
 
 
 
786959b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7b3fa89
cce9569
e43fe76
6c2ab00
e43fe76
 
 
 
 
 
 
 
 
 
 
 
6c2ab00
 
e43fe76
 
6c2ab00
e43fe76
cce9569
e43fe76
 
 
 
6c2ab00
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e43fe76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
"""Sampler note timing and audio effect contracts."""

from __future__ import annotations

from pathlib import Path
import json
import tempfile
import unittest
from unittest.mock import patch


class SamplerTest(unittest.TestCase):
    def test_empty_sheet_sage_melody_uses_f0_for_sampler(self):
        import numpy as np
        import pretty_midi
        import soundfile as sf
        from pipeline import generate_all

        with tempfile.TemporaryDirectory() as directory:
            folder = Path(directory)
            wave = folder / "hum.wav"
            t = np.arange(24000) / 24000
            sf.write(wave, .12 * np.sin(2 * np.pi * 261.626 * t), 24000)
            empty, abc, beat = (folder / name for name in
                                ("empty.mid", "empty.abc", "beat.lab"))
            pretty_midi.PrettyMIDI().write(str(empty))
            abc.write_text("unusable empty melody")

            def fake_mp3(_, target, __, **___):
                target.write_bytes(b"fake mp3")

            with (patch("pipeline.decode", return_value=(wave, wave, 1.)),
                  patch("pipeline.transcribe", return_value=(empty, abc, abc, beat)),
                  patch("pipeline.choose_bass_route", return_value=(-24, folder / "bass.sf2", 0, "test")),
                  patch("pipeline.render_instrument", return_value=(wave, empty)),
                  patch("pipeline.render_drums", return_value=(wave, empty, 3)),
                  patch("pipeline.pair_with_drums", return_value=(wave, wave)),
                  patch("pipeline.render_contour", return_value=wave),
                  patch("pipeline.render_preset", return_value=(wave, empty)),
                  patch("pipeline.apply_effect", return_value=wave),
                  patch("pipeline.encode_mp3", side_effect=fake_mp3)):
                result = list(generate_all(str(wave), "Флейта", {}, 4096, False))[-1]
            self.assertTrue(result[-1]["complete"])
            self.assertEqual(result[-1]["transcription_source"], "pYIN fallback")
            self.assertIn("SheetSage2 не нашёл нот", result[-1]["errors"]["sheet_sage"])
            self.assertEqual(len(pretty_midi.PrettyMIDI(str(empty)).instruments[0].notes), 1)
            self.assertNotIn("Vocal", abc.read_text())
            self.assertIsNone(result[12])
            self.assertEqual(result[-1]["errors"]["instrument_drums"],
                             "SheetSage2 не дал сетку долей; барабаны пропущены")
            self.assertNotIn("instrument_drums", result[-1]["source_timing_preserved"])

    def test_archive_keeps_original_bytes_and_uses_opaque_name(self):
        from pipeline import archive_recording, source_sha256

        with tempfile.TemporaryDirectory() as directory:
            root = Path(directory)
            source = root / "personal-name.m4a"
            source.write_bytes(b"original encoded audio bytes")
            archive_root = root / "private-bucket"
            archive_root.mkdir()
            digest = source_sha256(source)
            with patch("pipeline.RECORDINGS_DIR", archive_root):
                result = archive_recording(source, digest, 2.25)
            self.assertTrue(result["saved"])
            self.assertNotIn("personal-name", result["key"])
            saved_folder = archive_root / result["key"]
            self.assertEqual((saved_folder / "source.m4a").read_bytes(), source.read_bytes())
            metadata = json.loads((saved_folder / "metadata.json").read_text())
            self.assertEqual(metadata["source_sha256"], digest)
            self.assertEqual(metadata["source_seconds"], 2.25)

    def test_bass_font_sounds_at_both_ends_of_development_note_range(self):
        import numpy as np
        import pretty_midi
        import soundfile as sf
        from pipeline import SOUNDFONT_DIR, choose_bass_route, render_preset

        font = SOUNDFONT_DIR / "finger-bass.sf2"
        if not font.is_file():
            self.skipTest("Pinned FreePats bass SoundFont not installed")
        # The seen development recording spans MIDI 54..68. Keep this tiny
        # synthetic fixture separate from the source audio and its title.
        with tempfile.TemporaryDirectory() as directory:
            folder = Path(directory)
            score = pretty_midi.PrettyMIDI()
            voice = pretty_midi.Instrument(program=0)
            voice.notes = [pretty_midi.Note(90, 54, .1, .35),
                           pretty_midi.Note(90, 68, .6, .85)]
            score.instruments.append(voice)
            midi = folder / "notes.mid"
            score.write(str(midi))
            shift, chosen_font, program, source = choose_bass_route(midi)
            self.assertEqual((shift, chosen_font, program), (-24, font, 0))
            self.assertEqual(source, "FreePats Finger Bass YR")
            wav, _ = render_preset(midi, folder, "bass", 0, 1., folder / "log",
                                   soundfont=chosen_font, transpose=shift)
            audio, rate = sf.read(wav, dtype="float32", always_2d=True)
            for start in (.12, .62):
                span = audio[round(start * rate):round((start + .1) * rate)]
                self.assertGreater(float(np.sqrt(np.mean(span**2))), .001)

    def test_optional_yue_does_not_reclassify_sampler_outputs_as_missing(self):
        import numpy as np
        import pretty_midi
        import soundfile as sf
        from pipeline import generate_all

        abc_text = ('X:1\nM:4/4\nL:1/4\n'
                    'V: Vocal clef=treble name="Vocal Melody" snm="Vocal"\n'
                    'V: Ins clef=treble name="Ins Melody" snm="Inst."\n'
                    'K:C\nV: Vocal\nC|\nV: Ins\nZ|\n')
        with tempfile.TemporaryDirectory() as directory:
            folder = Path(directory)
            wave = folder / "input.wav"
            sf.write(wave, np.full(48000, .1, dtype="float32"), 48000)
            midi, abc, beat = (folder / name for name in
                               ("notes.mid", "melody.abc", "beat.lab"))
            score = pretty_midi.PrettyMIDI()
            track = pretty_midi.Instrument(0)
            track.notes.append(pretty_midi.Note(90, 60, .1, .8))
            score.instruments.append(track)
            score.write(str(midi))
            abc.write_text(abc_text)
            beat.write_text("0 1 4 4\n.5 2 4 4\n1 3 4 4\n")

            def fake_yue(_, __, output, ___, ____, _____):
                for name in ("yue_solo", "yue_drums", "yue_arrangement"):
                    target = output / name
                    target.mkdir()
                    sf.write(target / "raw.wav", np.full((48000, 2), .1,
                                                           dtype="float32"), 48000)
                    (target / "yue.json").write_text(json.dumps({"timing": {}}))
                    yield name

            def fake_mp3(_, target, __, **___):
                target.write_bytes(b"fake mp3")

            with (patch("pipeline.decode", return_value=(wave, wave, 1.)),
                  patch("pipeline.transcribe", return_value=(midi, abc, abc, beat)),
                  patch("pipeline.choose_bass_route", return_value=(-24, folder / "bass.sf2", 0, "test")),
                  patch("pipeline.render_instrument", return_value=(wave, midi)),
                  patch("pipeline.render_drums", return_value=(wave, midi, 3)),
                  patch("pipeline.pair_with_drums", return_value=(wave, wave)),
                  patch("pipeline.render_contour", return_value=wave),
                  patch("pipeline.render_preset", return_value=(wave, midi)),
                  patch("pipeline.apply_effect", return_value=wave),
                  patch("pipeline.encode_mp3", side_effect=fake_mp3),
                  patch("pipeline.render_yue_batch", side_effect=fake_yue)):
                final = list(generate_all(str(wave), "Флейта", {}, 4096, True))[-1]
            self.assertEqual(final[-1]["errors"], {})
            self.assertFalse(final[-1]["recording_archive"]["requested"])

            with (patch("pipeline.decode", return_value=(wave, wave, 1.)),
                  patch("pipeline.archive_recording", side_effect=OSError("bucket unavailable")),
                  patch("pipeline.transcribe", return_value=(midi, abc, abc, beat)),
                  patch("pipeline.choose_bass_route", return_value=(-24, folder / "bass.sf2", 0, "test")),
                  patch("pipeline.render_instrument", return_value=(wave, midi)),
                  patch("pipeline.render_drums", return_value=(wave, midi, 3)),
                  patch("pipeline.pair_with_drums", return_value=(wave, wave)),
                  patch("pipeline.render_contour", return_value=wave),
                  patch("pipeline.render_preset", return_value=(wave, midi)),
                  patch("pipeline.apply_effect", return_value=wave),
                  patch("pipeline.encode_mp3", side_effect=fake_mp3),
                  patch("pipeline.render_yue_batch", side_effect=fake_yue)):
                failed = list(generate_all(str(wave), "Флейта", {}, 4096, True,
                                           save_recording=True))[-1]
            self.assertEqual(failed[-1]["errors"]["recording_archive"], "bucket unavailable")
            self.assertFalse(failed[-1]["recording_archive"]["saved"])
            self.assertTrue(failed[-1]["complete"])

    def test_bass_register_preserves_note_times(self):
        import pretty_midi
        from pipeline import choose_bass_route, render_preset

        with tempfile.TemporaryDirectory() as directory:
            folder = Path(directory)
            original = pretty_midi.PrettyMIDI()
            voice = pretty_midi.Instrument(program=0)
            voice.notes = [
                pretty_midi.Note(90, 60, .125, .375),
                pretty_midi.Note(90, 64, .5, .875),
            ]
            original.instruments.append(voice)
            source = folder / "source.mid"
            original.write(str(source))
            shift, _, _, _ = choose_bass_route(source)
            self.assertEqual(shift, -24)
            with patch("pipeline.render_midi", return_value=folder / "bass.wav"):
                _, path = render_preset(source, folder, "bass", 0, 1.,
                                        folder / "log", transpose=shift)
            notes = pretty_midi.PrettyMIDI(str(path)).instruments[0].notes
            self.assertEqual([n.pitch for n in notes], [36, 40])
            for before, after in zip(voice.notes, notes):
                self.assertAlmostEqual(before.start, after.start, places=4)
                self.assertAlmostEqual(before.end, after.end, places=4)

    def test_bass_route_automatically_shifts_or_uses_full_bank(self):
        import pretty_midi
        from pipeline import choose_bass_route, GM_SOUNDFONT

        with tempfile.TemporaryDirectory() as directory:
            midi = Path(directory) / "notes.mid"
            for pitches, expected in (((42, 54), (-12, "FreePats Finger Bass YR")),
                                      ((50, 80), (-24, "FluidR3 Fingered Bass fallback"))):
                score = pretty_midi.PrettyMIDI()
                track = pretty_midi.Instrument(0)
                track.notes = [pretty_midi.Note(90, pitch, index * .3,
                                                index * .3 + .2)
                               for index, pitch in enumerate(pitches)]
                score.instruments.append(track)
                score.write(str(midi))
                shift, font, program, source = choose_bass_route(midi)
                self.assertEqual((shift, source), expected)
                if source.endswith("fallback"):
                    self.assertEqual((font, program), (GM_SOUNDFONT, 33))

    def test_effects_preserve_duration_and_change_waveform(self):
        import numpy as np
        import soundfile as sf
        from pipeline import apply_effect

        rate = 48000
        t = np.arange(rate, dtype=np.float32) / rate
        source_wave = (.12 * np.sin(2 * np.pi * 220 * t)).astype("float32")
        with tempfile.TemporaryDirectory() as directory:
            folder = Path(directory)
            source = folder / "source.wav"
            sf.write(source, source_wave, rate, subtype="FLOAT")
            for kind in ("guitar", "bass"):
                result = apply_effect(source, folder / f"{kind}.wav", kind,
                                      1., folder / "log")
                output, output_rate = sf.read(result, dtype="float32",
                                              always_2d=True)
                self.assertEqual(output_rate, rate)
                self.assertEqual(len(output), rate)
                self.assertTrue(np.isfinite(output).all())
                self.assertGreater(np.mean(np.abs(output[:, 0] - source_wave)), .001)


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