diff --git a/public/sw.js b/public/sw.js index 2ceea3b..3066cf8 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v29'; +const CACHE = 'hub-v30'; const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/tools/transcript-test.js b/tools/transcript-test.js index d00def4..b068f1b 100644 --- a/tools/transcript-test.js +++ b/tools/transcript-test.js @@ -5,10 +5,15 @@ let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m); (async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'trt-')); + let installed = false; const calls = []; const srv = http.createServer((req, res) => { const body = []; req.on('data', d => body.push(d)); req.on('end', () => { const b = Buffer.concat(body).toString('latin1'); + calls.push(req.method + ' ' + req.url); + if (req.method === 'GET' && req.url === '/v1/models/Systran/faster-whisper-small') { res.writeHead(installed ? 200 : 404); return res.end('{}'); } + if (req.method === 'POST' && req.url === '/v1/models') { installed = /faster-whisper-small/.test(b); res.writeHead(installed ? 201 : 422); return res.end('{}'); } // (this version of the service downloads through a body, the older path form is refused) if (req.url === '/v1/audio/transcriptions' && req.method === 'POST') { + if (!installed) { res.writeHead(404); return res.end('{"detail":"Model is not installed locally"}'); } if (!/response_format/.test(b) || !/faster-whisper-small/.test(b) || !/hello-audio/.test(b)) { res.writeHead(400); return res.end('bad form'); } res.writeHead(200, { 'content-type': 'application/json' }); return res.end(JSON.stringify({ text: 'x', segments: [{ start: 0, end: 2.5, text: ' Welcome to the show. ' }, { start: 2.5, end: 6, text: ' Today we talk about bread.' }, { start: 6, end: 7, text: ' ' }] })); } @@ -36,6 +41,7 @@ let bad = 0; const ok = (c, m) => { console.log((c ? 'PASS ' : 'FAIL ') + m); const audio = path.join(dir, 'a.bin'); fs.writeFileSync(audio, 'hello-audio'); const r = await tr.whisper(audio, 'audio'); ok(r && r.found && r.lines.length === 2 && r.lines[0].text === 'Welcome to the show.' && r.lines[1].text === 'Today we talk about bread.' && r.source === 'Transcribed on your server', 'speech-to-text segments become timed lines (blank ones dropped, tags removed)'); + ok(calls.includes('POST /v1/models/Systran/faster-whisper-small') && calls.includes('POST /v1/models') && installed, 'a model that is not installed is downloaded first (trying the older form, then the newer)'); ok(r.lines[1].t === 2500 && r.lines[1].e === 2500 + 6000, 'times are in milliseconds'); srv.close(); fs.rmSync(dir, { recursive: true, force: true }); console.log(bad ? '\n' + bad + ' check(s) failed' : '\nAll transcript checks passed'); process.exit(bad ? 1 : 0); diff --git a/transcripts.js b/transcripts.js index 45ce57a..4aa64fb 100644 --- a/transcripts.js +++ b/transcripts.js @@ -69,11 +69,18 @@ const jobs = new Map(); // key -> { state: 'running' | 'error', since, error } let chain = Promise.resolve(); // one at a time: it is heavy const fileOf = key => path.join(cfg.dir, key + '.json'); const keyFor = (kind, id, src) => crypto.createHash('sha1').update(kind + '|' + id + '|' + src).digest('hex'); -let modelReady = null; // the service must have the model on disk before it can use it: ask for it once (a no-op when it is already there) -const ensureModel = () => modelReady || (modelReady = fetch(cfg.api + '/v1/models/' + cfg.model, { method: 'POST', signal: AbortSignal.timeout(30 * 60e3) }) - .then(r => { if (!r.ok && r.status !== 409) throw new Error('status ' + r.status); }).catch(e => { modelReady = null; if (/^status 4\d\d/.test(e.message)) return; throw e; })); +let modelReady = null; // the service must have the model on disk before it can use it: check, and ask for it once if it is missing +async function ensureModel() { + const id = cfg.model, enc = id.split('/').map(encodeURIComponent).join('/'), seen = []; + const call = async (method, url, body) => { const r = await fetch(cfg.api + url, { method, headers: body ? { 'content-type': 'application/json' } : {}, body, signal: AbortSignal.timeout(30 * 60e3) }); if (r.ok || r.status === 409) return true; seen.push(`${method} ${url}: ${r.status} ${(await r.text()).slice(0, 120)}`); return false; }; + if ((await fetch(cfg.api + '/v1/models/' + enc, { signal: AbortSignal.timeout(20000) })).ok) return; // already installed + // (the download call has differed between versions of the service, so the known forms are tried in turn) + if (await call('POST', '/v1/models/' + enc) || await call('POST', '/v1/models', JSON.stringify({ id })) || await call('POST', '/v1/models?model_name=' + encodeURIComponent(id))) return; + throw new Error('The transcription service would not download the model ' + id + ' (' + seen.join('; ') + ')'); +} +const modelOnce = () => modelReady || (modelReady = ensureModel().catch(e => { modelReady = null; throw e; })); async function whisper(file, name) { - try { await ensureModel(); } catch (e) { throw new Error(`Could not reach the transcription service at ${cfg.api} (${why(e)}). Is the whisper container running? Check its log.`); } + try { await modelOnce(); } catch (e) { throw /would not download/.test(e.message) ? e : new Error(`Could not reach the transcription service at ${cfg.api} (${why(e)}). Is the whisper container running? Check its log.`); } const form = new FormData(); form.append('file', await fs.openAsBlob(file), name || 'audio'); form.append('model', cfg.model); form.append('response_format', 'verbose_json'); form.append('timestamp_granularities[]', 'segment');