File size: 9,047 Bytes
8cc1163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""One-shot backend worker for the isolated interpreter.

Reads a JSON request {"inputs": {...}} on stdin and prints a JSON response
{"ok": bool, "outputs": {...}} on stdout. Media are exchanged by file path.
All library stdout noise is redirected to stderr so stdout carries only the
JSON protocol.
"""
from __future__ import annotations

import contextlib
import json
import sys
import traceback

import os
import json as _json
import pickle
import urllib.parse
import urllib.request
import tempfile
import warnings

warnings.filterwarnings("ignore")
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3")
os.environ.setdefault("CUDA_VISIBLE_DEVICES", "")

import numpy as np
import soundfile as sf
import librosa

import ddsp
import ddsp.training
from ddsp.training.postprocessing import detect_notes, fit_quantile_transform
import gin
import tensorflow.compat.v2 as tf

SAMPLE_RATE = 16000
PRETRAINED_MODELS = ["Violin", "Flute", "Flute2", "Trumpet", "Tenor_Saxophone"]
_GCS_BUCKET = "ddsp"
_GCS_PREFIX = "models/timbre_transfer_colab/2021-07-08"
_MODELS_DIR = "/tmp/ddsp_pretrained"
os.makedirs(_MODELS_DIR, exist_ok=True)


def _ensure_model(model_name):
    dest = os.path.join(_MODELS_DIR, model_name)
    gin_file = os.path.join(dest, "operative_config-0.gin")
    if os.path.exists(gin_file):
        return dest
    os.makedirs(dest, exist_ok=True)
    prefix = f"{_GCS_PREFIX}/solo_{model_name.lower()}_ckpt/"
    list_url = (
        f"https://storage.googleapis.com/storage/v1/b/{_GCS_BUCKET}/o"
        f"?prefix={urllib.parse.quote(prefix, safe='')}"
    )
    with urllib.request.urlopen(list_url, timeout=60) as response:
        listing = _json.loads(response.read().decode("utf-8"))
    items = listing.get("items") or []
    if not items:
        raise RuntimeError(f"Could not list checkpoint files for {model_name!r}.")
    for item in items:
        obj = item.get("name") or ""
        fname = obj.rsplit("/", 1)[-1]
        if not fname:
            continue
        out = os.path.join(dest, fname)
        if not os.path.exists(out):
            url = f"https://storage.googleapis.com/{_GCS_BUCKET}/{urllib.parse.quote(obj, safe='')}"
            urllib.request.urlretrieve(url, out)
    if not os.path.exists(gin_file):
        raise RuntimeError(f"Downloaded {model_name!r} but operative_config-0.gin is missing.")
    return dest


def _shift_ld(af, ld_shift=0.0):
    af["loudness_db"] += ld_shift
    return af


def _shift_f0(af, pitch_shift=0.0):
    af["f0_hz"] *= 2.0 ** (pitch_shift)
    af["f0_hz"] = np.clip(af["f0_hz"], 0.0, librosa.midi_to_hz(110.0))
    return af


def _get_tuning_factor(f0_midi, f0_confidence, mask_on):
    tuning_factors = np.linspace(-0.5, 0.5, 101)
    midi_diffs = (f0_midi[mask_on][:, np.newaxis] - tuning_factors[np.newaxis, :]) % 1.0
    midi_diffs[midi_diffs > 0.5] -= 1.0
    weights = f0_confidence[mask_on][:, np.newaxis]
    cost_diffs = np.mean(weights * np.abs(midi_diffs), axis=0)
    f0_at = f0_midi[mask_on][:, np.newaxis] - midi_diffs
    deltas = (np.diff(f0_at, axis=0) != 0.0).astype(float)
    cost_deltas = np.mean(weights[:-1] * deltas, axis=0)
    norm = lambda x: (x - np.mean(x)) / np.std(x)
    cost = norm(cost_deltas) + norm(cost_diffs)
    return tuning_factors[np.argmin(cost)]


def _auto_tune(f0_midi, tuning_factor, mask_on, amount=0.0):
    major_scale = np.ravel([np.array([0, 2, 4, 5, 7, 9, 11]) + 12 * i for i in range(10)])
    all_scales = np.stack([major_scale + i for i in range(12)])
    f0_on = f0_midi[mask_on]
    f0_diff_tsn = f0_on[:, np.newaxis, np.newaxis] - all_scales[np.newaxis, :, :]
    f0_diff_ts = np.min(np.abs(f0_diff_tsn), axis=-1)
    f0_diff_s = np.mean(f0_diff_ts, axis=0)
    scale_idx = np.argmin(f0_diff_s)
    f0_diff_tn = f0_midi[:, np.newaxis] - all_scales[scale_idx][np.newaxis, :]
    note_idx = np.argmin(np.abs(f0_diff_tn), axis=-1)
    midi_diff = np.take_along_axis(f0_diff_tn, note_idx[:, np.newaxis], axis=-1)[:, 0]
    return f0_midi - amount * midi_diff


def run_timbre_transfer(inputs):
    audio_path = inputs["audio"]
    model_name = inputs.get("model_name") or "Violin"
    threshold = float(inputs.get("threshold", 1.0))
    adjust = bool(inputs.get("adjust", True))
    quiet = float(inputs.get("quiet", 20.0))
    autotune = float(inputs.get("autotune", 0.0))
    pitch_shift = float(inputs.get("pitch_shift", 0.0))
    loudness_shift = float(inputs.get("loudness_shift", 0.0))

    if not audio_path:
        raise RuntimeError("No input audio provided.")
    if model_name not in PRETRAINED_MODELS:
        raise RuntimeError(f"Unknown model {model_name!r}; choose {PRETRAINED_MODELS}.")

    audio, _ = librosa.load(audio_path, sr=SAMPLE_RATE, mono=True)
    audio = audio.astype(np.float32)[np.newaxis, :]

    model_dir = _ensure_model(model_name)
    gin_file = os.path.join(model_dir, "operative_config-0.gin")

    dataset_stats = None
    stats_file = os.path.join(model_dir, "dataset_statistics.pkl")
    if os.path.exists(stats_file):
        with open(stats_file, "rb") as fh:
            dataset_stats = pickle.load(fh)

    with gin.unlock_config():
        gin.parse_config_file(gin_file, skip_unknown=True)

    ddsp.spectral_ops.reset_crepe()
    af = ddsp.training.metrics.compute_audio_features(audio)
    af = {k: (v.numpy() if hasattr(v, "numpy") else v) for k, v in af.items()}
    af["loudness_db"] = af["loudness_db"].astype(np.float32)

    ckpt_files = [f for f in os.listdir(model_dir) if "ckpt" in f]
    if not ckpt_files:
        raise RuntimeError(f"No checkpoint files in {model_dir}.")
    ckpt = os.path.join(model_dir, ckpt_files[0].split(".")[0])

    time_steps_train = gin.query_parameter("F0LoudnessPreprocessor.time_steps")
    n_samples_train = gin.query_parameter("Harmonic.n_samples")
    hop_size = int(n_samples_train / time_steps_train)
    time_steps = int(audio.shape[1] / hop_size)
    n_samples = time_steps * hop_size

    with gin.unlock_config():
        gin.parse_config([
            f"Harmonic.n_samples = {n_samples}",
            f"FilteredNoise.n_samples = {n_samples}",
            f"F0LoudnessPreprocessor.time_steps = {time_steps}",
            "oscillator_bank.use_angular_cumsum = True",
        ])

    for key in ["f0_hz", "f0_confidence", "loudness_db"]:
        af[key] = af[key][:time_steps]
    af["audio"] = af["audio"][:, :n_samples]

    af_mod = {k: (v.copy() if hasattr(v, "copy") else v) for k, v in af.items()}
    if adjust and dataset_stats is not None:
        mask_on, note_on_value = detect_notes(af["loudness_db"], af["f0_confidence"], threshold)
        if np.any(mask_on):
            target_mean_pitch = dataset_stats["mean_pitch"]
            pitch = ddsp.core.hz_to_midi(af["f0_hz"])
            mean_pitch = np.mean(pitch[mask_on])
            p_diff = target_mean_pitch - mean_pitch
            p_diff_octave = p_diff / 12.0
            round_fn = np.floor if p_diff_octave > 1.5 else np.ceil
            af_mod = _shift_f0(af_mod, round_fn(p_diff_octave))
            _, loudness_norm = fit_quantile_transform(
                af["loudness_db"], mask_on, inv_quantile=dataset_stats["quantile_transform"]
            )
            mask_off = np.logical_not(mask_on)
            loudness_norm[mask_off] -= quiet * (1.0 - note_on_value[mask_off][:, np.newaxis])
            loudness_norm = np.reshape(loudness_norm, af["loudness_db"].shape)
            af_mod["loudness_db"] = loudness_norm
            if autotune:
                f0_midi = np.array(ddsp.core.hz_to_midi(af_mod["f0_hz"]))
                tuning_factor = _get_tuning_factor(f0_midi, af_mod["f0_confidence"], mask_on)
                f0_midi_at = _auto_tune(f0_midi, tuning_factor, mask_on, amount=autotune)
                af_mod["f0_hz"] = ddsp.core.midi_to_hz(f0_midi_at)

    af_mod = _shift_ld(af_mod, loudness_shift)
    af_mod = _shift_f0(af_mod, pitch_shift)

    model = ddsp.training.models.Autoencoder()
    model.restore(ckpt)
    _ = model(af_mod, training=False)
    outputs_tf = model(af_mod, training=False)
    audio_gen = np.array(model.get_audio_from_outputs(outputs_tf))
    if audio_gen.ndim == 2:
        audio_gen = audio_gen[0]

    out_path = os.path.join(tempfile.mkdtemp(), "ddsp_output.wav")
    sf.write(out_path, audio_gen.astype(np.float32), SAMPLE_RATE)
    return out_path


def _run(inputs):
    outputs = {"out_audio": run_timbre_transfer(inputs)}
    return outputs


def main():
    try:
        request = json.load(sys.stdin)
    except Exception as exc:
        print(json.dumps({"ok": False, "error": f"invalid request: {exc!r}"}), flush=True)
        return 2
    inputs = request.get("inputs") or {}
    try:
        with contextlib.redirect_stdout(sys.stderr):
            outputs = _run(inputs)
        payload = {"ok": True, "outputs": outputs}
    except Exception:
        payload = {"ok": False, "error": traceback.format_exc()[-3000:]}
    print(json.dumps(payload), flush=True)
    return 0 if payload["ok"] else 1


if __name__ == "__main__":
    raise SystemExit(main())