File size: 15,839 Bytes
f69ab43
 
fcf04cd
f69ab43
fcf04cd
f69ab43
 
 
 
 
 
2cdef80
 
 
 
 
 
 
 
 
 
 
 
f69ab43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2cdef80
 
 
 
 
 
 
 
f69ab43
fcf04cd
 
2cdef80
 
 
f69ab43
2cdef80
 
f69ab43
 
 
2cdef80
 
 
 
 
66cde24
 
 
2cdef80
 
 
 
 
 
 
 
 
 
 
 
f69ab43
 
 
2cdef80
f69ab43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2cdef80
 
f69ab43
2cdef80
 
 
 
 
 
 
 
f69ab43
2cdef80
 
 
 
 
 
 
 
 
 
 
f69ab43
 
2cdef80
 
f69ab43
 
 
 
 
 
 
 
 
 
 
 
 
2cdef80
f69ab43
 
 
 
 
 
 
 
 
 
 
 
fcf04cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f69ab43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcf04cd
f69ab43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
import type {Client} from '@gradio/client';
import type {Download} from '@/lib/types';
import {ENGINE,YUE2,JOBS,type Engine,type Input,type Job,type Spec,asFile,explain,route} from '@/lib/jobs';
import {encodeWav} from './audio';
import type {Model} from './models';

/** Raised when the session has lapsed, so the studio can show sign-in without losing the project. */
export class SignedOut extends Error{}

/** How far a job has got: the engine's own fraction when it reports one, and the queue's estimate of the time left. */
export type Progress={fraction:number|null;eta:number|null;queue:number|null;running:boolean};
/**
 * `job` hears the server's ID for a Modal job as soon as it exists, so the
 * caller can keep it and pick the job up again after a refresh (resumeJob).
 * Jobs on the Hugging Face Spaces run in the page itself and have no such ID.
 */
type Call={signal:AbortSignal;status:(text:string)=>void;progress?:(p:Progress)=>void;job?:(id:string)=>void};

/**
 * Abort with this reason when the page is going away rather than the user
 * stopping the job: a Modal job then keeps running, to be picked up again.
 */
export const LEAVING='leaving';

let cached:{token:string;expires:number}|null=null;
/**
 * The signed-in user's Hugging Face token. The browser calls the engines
 * itself, so each user's requests come from their own connection and count
 * against their own GPU allowance.
 */
export async function hfToken(){
 if(cached&&cached.expires-60000>Date.now())return cached.token;
 const response=await fetch('/api/auth/token',{cache:'no-store'});
 const data=await response.json().catch(()=>({}));
 if(response.status===401){cached=null;throw new SignedOut(data.error||'Sign in again to continue.');}
 if(!response.ok||typeof data.token!=='string')throw new Error('Could not reach Kiku AI. Check your connection and try again.');
 cached={token:data.token,expires:Number(data.expires)||Date.now()+600000};
 return cached.token;
}

/** Drop the cached token, after the user signs in again (perhaps with more permissions). */
export function forgetToken(){cached=null;}

const pause=(ms:number,signal:AbortSignal)=>new Promise<void>(done=>{
 const timer=setTimeout(done,ms);
 signal.addEventListener('abort',()=>{clearTimeout(timer);done();},{once:true});
});

/**
 * Failures that are Hugging Face's gateway or a Space waking up, not the job:
 * rate limits, 5xx answers (often as an HTML error page) and dropped
 * connections. These are worth retrying; anything else is a real error.
 */
const PASSING=/\b(429|50[0234])\b|too many requests|starting|bad gateway|<!doctype html|<html|fetch failed|failed to fetch|network/i;

/** Connect to a Space by its own URL, riding out short gateway refusals with a jittered backoff. */
async function connect(engine:Engine,token:string,status:(text:string)=>void,signal:AbortSignal){
 const {Client}=await import('@gradio/client');
 const waits=[3000,6000,12000,24000];
 for(let attempt=0;;attempt++){
  try{return await Client.connect(engine.origin,{events:['status','data'],token:token as `hf_${string}`});}
  catch(error){
   const message=error instanceof Error?error.message:String(error);
   if(!PASSING.test(message)||attempt>=waits.length||signal.aborted)throw error;
   status('Hugging Face is busy. Trying again in a moment…');
   await pause(waits[attempt]+Math.random()*2000,signal);
   if(signal.aborted)throw error;
  }
 }
}

/** Seconds of audio in a 16-bit WAV, read from its header; null for other formats. */
async function wavSeconds(file:Blob){
 const head=new DataView(await file.slice(0,44).arrayBuffer());
 if(head.byteLength<44||head.getUint32(0)!==0x52494646||head.getUint32(8)!==0x57415645)return null;
 const rate=head.getUint32(24,true),channels=head.getUint16(22,true);
 return rate&&channels?(file.size-44)/(rate*channels*2):null;
}

const offline=()=>typeof navigator!=='undefined'&&navigator.onLine===false;
/** Resolves when the browser is back online, or the job is stopped. */
const backOnline=(signal:AbortSignal)=>new Promise<void>(done=>{
 if(!offline()){done();return;}
 const finish=()=>{window.removeEventListener('online',finish);signal.removeEventListener('abort',finish);done();};
 window.addEventListener('online',finish);signal.addEventListener('abort',finish,{once:true});
});

/**
 * YuE2-Turbo falls back to our private Modal GPU, through the authenticated
 * app server, when ZeroGPU cannot serve it. The job lives on the server, not in this connection: when
 * Hugging Face's proxy drops the stream, or the browser goes offline, this
 * reattaches and carries on from the last event it saw instead of losing the
 * generation.
 */
async function runModal<T>(name:Job,input:Input,call:Call):Promise<T>{
 const {signal,status}=call;
 const form=input instanceof FormData?input:new FormData();
 if(!(input instanceof FormData))for(const [key,value] of Object.entries(input))if(value!==undefined&&value!==null)form.append(key,String(value));
 status('Starting Modal GPU…');
 const response=await fetch('/api/modal/jobs?job='+encodeURIComponent(name),{method:'POST',body:form,signal}).catch(error=>{
  if(signal.aborted)throw error;throw new Error('Could not reach Kiku. Check your connection and try again; nothing was started.');});
 if(!response.ok){
  const data=await response.json().catch(()=>({}));
  if(response.status===401)throw new SignedOut(data.error||'Sign in again to continue.');
  // A 502-504 with no message is Hugging Face's proxy answering while Kiku itself restarts (a new deployment).
  throw new Error(data.error||(response.status>=502&&response.status<=504?'Kiku is restarting. Try again in a minute; nothing was started.'
   :'Could not reach the Modal GPU. Try again.'));
 }
 return followModal<T>(response,'',call);
}

/** Pick up a Modal job started before a refresh, by the ID `job` heard, and follow it to its result. */
export function resumeJob<T>(id:string,call:Call):Promise<T>{
 call.status('Picking up where this left off…');
 return followModal<T>(null,id,call);
}

async function followModal<T>(response:Response|null,id:string,{signal,status,progress,job}:Call):Promise<T>{
 let seq=0,result:T|undefined,failure:string|null=null;
 const receive=(line:string)=>{
  if(!line.trim())return;
  const event=JSON.parse(line);
  if(event.type==='job'){if(!id){id=event.id;job?.(id);}return;}
  if(typeof event.seq==='number'){if(event.seq<=seq)return;seq=event.seq;}
  if(event.type==='error')failure=String(event.error||'The Modal generation could not finish.');
  if(event.type==='status'){status(event.message);if(event.progress)progress?.(event.progress);}
  if(event.type==='result')result=event.data as T;
 };
 const read=async(response:Response)=>{
  if(!response.body)return;
  const reader=response.body.getReader(),decoder=new TextDecoder();let pending='';
  try{
   for(;;){
    const {done,value}=await reader.read();signal.throwIfAborted();
    pending+=decoder.decode(value,{stream:!done});
    let newline:number;while((newline=pending.indexOf('\n'))>=0){receive(pending.slice(0,newline));pending=pending.slice(newline+1);}
    if(done){receive(pending);return;}
   }
  }finally{await reader.cancel().catch(()=>{});reader.releaseLock();}
 };
 // Stopping a job in the studio stops it on the GPU too; leaving the page does not.
 const cancel=()=>{if(id&&signal.reason!==LEAVING&&result===undefined&&failure===null)void fetch('/api/modal/jobs?id='+encodeURIComponent(id),{method:'DELETE',keepalive:true}).catch(()=>{});};
 signal.addEventListener('abort',cancel,{once:true});
 // Before reattaching, wait until the browser is back online, or back off after a
 // dropped connection. Only dropped connections count against the retries.
 let drops=0;
 const wait=async()=>{
  if(offline()){status('You are offline. This carries on by itself when you are back online…');await backOnline(signal);}
  else{
   if(drops>=8)throw new Error('The connection to Kiku dropped and could not be restored. Try again; your project is safe.');
   status('Connection dropped. Reconnecting…');await pause(Math.min(15000,1000*2**drops++),signal);
  }
  signal.throwIfAborted();
 };
 try{
  for(;;){
   if(response){
    await read(response).catch(error=>{if(signal.aborted)throw error;});
    if(failure!==null)throw new Error(failure);
    if(result!==undefined)break;
    if(!id)throw new Error('The connection to Kiku dropped before the job started. Try again; your project is safe.');
    await wait();
   }
   response=await fetch('/api/modal/jobs?id='+encodeURIComponent(id)+'&after='+seq,{signal,cache:'no-store'}).catch(error=>{if(signal.aborted)throw error;return null;});
   if(response?.status===401)throw new SignedOut('Sign in again to continue.');
   if(response?.status===404)throw new Error('Kiku restarted, or this finished too long ago, so the result was lost. Try again; your project is safe.');
   if(response?.ok)drops=0;else{response=null;await wait();}
  }
 }finally{signal.removeEventListener('abort',cancel);}
 progress?.({fraction:1,eta:0,queue:null,running:true});
 return result as T;
}

/**
 * Run a job on its Space and follow it to the result.
 *
 * A job that never reached the queue (the upload or queue join hit a gateway
 * error) is sent again. Once it is queued it is never resent, so a user is
 * never charged GPU time twice for one click.
 */
export async function runJob<T>(name:Job,input:Input,{signal,status,progress,job}:Call):Promise<T>{
 const spec=JOBS[name],picked=route(spec,input);
 if('error' in picked)throw new Error(picked.error);
 const {engine,model}=picked,built=spec.build(input,model);
 if('error' in built)throw new Error(built.error);
 const file=input instanceof FormData?input.get('audio'):null,audio=file instanceof Blob&&file.size?file:null;
 if(!audio&&(spec.audio??'required')==='required')throw new Error('There was no audio to send.');
 if(audio){
  const limits=spec.limits||{min:3,max:240},seconds=await wavSeconds(audio);
  if(audio.size>100*1024*1024)throw new Error('Select less audio. Uploads are limited to 100 MB.');
  if(seconds!==null&&seconds<limits.min-0.05)throw new Error(`Use at least ${limits.min} seconds of audio.`);
  if(seconds!==null&&seconds>limits.max+0.05)throw new Error(`The engine takes up to ${limits.max/60} minutes at once. Select a shorter part; nothing was submitted.`);
 }
 const call={signal,status,progress,job};
 if(engine!==YUE2)return runSpace<T>(engine,spec,model,built,audio,call);
 // YuE2-Turbo runs first on the Kiku-Engine ZeroGPU Space, on the user's own GPU
 // allowance. When ZeroGPU cannot serve it (allowance used up, GPU time ran out,
 // the Space down or overloaded), our private Modal GPU takes the job instead. A
 // message the engine wrote about the request itself goes to the user as it is.
 try{return await runSpace<T>(ENGINE,spec,model,built,audio,call,true);}
 catch(error){
  if(signal.aborted||!(error instanceof Unavailable))throw error;
  status('The free GPU could not take this, so Kiku’s own GPU is taking over…');
  return runModal<T>(name,input,call);
 }
}

/** ZeroGPU could not run the job; the message says why. */
class Unavailable extends Error{}
/**
 * ZeroGPU and gateway failures, and the engine's catch-all for a crashed GPU
 * worker (see failed() in the engine), as opposed to its messages about the request.
 */
const UNAVAILABLE=/quota|GPU.*duration|exceeded|allowance|gpu task aborted|timed out|timeout|no gpu|unavailable|starting|\b(429|50[0234])\b|too many requests|rate.?limit|<!doctype html|<html|fetch failed|failed to fetch|network|zerogpu|cuda|out of memory|too long to process at once|details are in the space logs|returned nothing|^generation failed\.?$|^error$/i;

/** Run a job on a Hugging Face Space with the user's own token. `fallback` raises Unavailable for failures another GPU could get past. */
async function runSpace<T>(engine:Engine,spec:Spec,model:Model,built:{args:(audio:unknown)=>unknown[]},audio:Blob|null,
 {signal,status,progress}:Call,fallback=false):Promise<T>{
 const endpoint=typeof spec.endpoint==='function'?spec.endpoint(model):spec.endpoint;
 const token=await hfToken();
 const {handle_file}=await import('@gradio/client');
 const args=built.args(audio?handle_file(audio):null);
 let client:Client|undefined,result:unknown=null,received=false;
 try{
  for(let attempt=0;;attempt++){
   status(attempt?'Hugging Face hiccuped. Sending it again…':audio?'Sending your audio…':'Connecting to the studio…');
   client=await connect(engine,token,status,signal);
   signal.throwIfAborted();
   const job=client.submit(endpoint,args);
   const stop=()=>{void job.cancel();};
   signal.addEventListener('abort',stop,{once:true});
   let queued=false;
   try{
    for await(const event of job){
     if(signal.aborted)break;
     if(event.type==='status'){
      if(event.stage==='error')throw new Error(typeof event.message==='string'?event.message:'Generation failed.');
      queued=true;
      const step=event.progress_data?.find(p=>p.desc||p.progress!==null||p.length);
      const fraction=step?.progress??(step?.index!=null&&step.length?(step.index+1)/step.length:null);
      // Gradio says "pending" both while queued and while running; process_starts marks the switch.
      const running=(event as {original_msg?:string}).original_msg==='process_starts'||!!step||event.stage==='generating';
      progress?.({fraction:typeof fraction==='number'&&Number.isFinite(fraction)?Math.min(1,Math.max(0,fraction)):null,
       eta:typeof event.eta==='number'&&event.eta>0?event.eta:null,queue:!running&&typeof event.position==='number'?event.position:null,running});
      status(step?.desc||(event.stage==='pending'?(spec.waiting||'Waiting for an available GPU…'):'Working on your music…'));
     }
     if(event.type==='data'){received=true;result=spec.shape(event.data as unknown[],value=>asFile(engine,value),model);}
    }
    break;
   }catch(error){
    const message=error instanceof Error?error.message:String(error);
    if(queued||attempt>=2||signal.aborted||!PASSING.test(message))throw error;
    client.close();
    await pause(4000*(attempt+1),signal);
   }finally{signal.removeEventListener('abort',stop);}
  }
  signal.throwIfAborted();
  if(!received||!result)throw new Error('The engine returned nothing. Please try again.');
  progress?.({fraction:1,eta:0,queue:null,running:true});
  return result as T;
 }catch(error){
  if(signal.aborted||error instanceof SignedOut)throw error;
  const message=error instanceof Error?error.message:'Generation could not finish.';
  if(/\b401\b|unauthori[sz]ed|invalid.*token/i.test(message)){cached=null;throw new SignedOut('Sign in again to continue.');}
  if(fallback&&UNAVAILABLE.test(message.trim()))throw new Unavailable(message);
  throw new Error(explain(message).slice(0,600));
 }finally{client?.close();}
}

/**
 * Fetch a result file straight from the Space that made it. Hugging Face's
 * gateway drops some requests with a 5xx, so those are retried; the finished
 * job is not lost.
 */
export async function fetchResult(file:Download,signal:AbortSignal){
 for(let attempt=0;attempt<5;attempt++){
  if(attempt)await pause(2000*attempt,signal);
  signal.throwIfAborted();
  const response=await fetch(file.url,{signal}).catch(error=>{if(signal.aborted)throw error;return null;});
  // A download cut off partway ("network error") is fetched again from the start.
  if(response?.ok)try{return await response.arrayBuffer();}catch(error){if(signal.aborted)throw error;continue;}
  if(response?.status===404||response?.status===403)throw new Error('That file has expired. Run the step again.');
 }
 throw new Error('The result could not be downloaded. Try again; your project is safe.');
}

export function wavFile(buffer:AudioBuffer,name='selection.wav'){
 return new File([encodeWav(buffer)],name,{type:'audio/wav'});
}