mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 23:17:23 +00:00
feat: add speech2text (#1855)
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test (20.x) (push) Has been cancelled
test / test (22.x) (push) Has been cancelled
test / backend (node env, api-test) (22.x) (push) Has been cancelled
test / puterjs (browser env, playwright) (22.x) (push) Has been cancelled
test / puterjs (node env, vitest) (22.x) (push) Has been cancelled
Docker Image CI / build-and-push-image (push) Has been cancelled
Maintain Release Merge PR / update-release-pr (push) Has been cancelled
release-please / release-please (push) Has been cancelled
test / test (20.x) (push) Has been cancelled
test / test (22.x) (push) Has been cancelled
test / backend (node env, api-test) (22.x) (push) Has been cancelled
test / puterjs (browser env, playwright) (22.x) (push) Has been cancelled
test / puterjs (node env, vitest) (22.x) (push) Has been cancelled
- A new stt driver in `AIInterfaceService` and `PuterAIModule`. - added methods for audio transcription and translation in the speech-to-text interface. - updated cost mapping for stt models in `openAiCostMap.ts`. - Updated permissions and interfaces to support new speech-to-text features.
This commit is contained in:
Generated
+97
-82
@@ -2316,10 +2316,6 @@
|
||||
"resolved": "src/phoenix",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@heyputer/puter-api-test": {
|
||||
"resolved": "tools/api-tester",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@heyputer/puter-wisp": {
|
||||
"resolved": "src/puter-wisp",
|
||||
"link": true
|
||||
@@ -14198,6 +14194,101 @@
|
||||
"integrity": "sha512-5vQEh3y+DG/lMPM0mCGPDnyV8chYg/g7rl6v3Gd8WMF9S429ox3Xk8qrk174kWhG767KQMqqxLD1WnGd77hiew==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/music-metadata": {
|
||||
"version": "7.14.0",
|
||||
"resolved": "https://registry.npmjs.org/music-metadata/-/music-metadata-7.14.0.tgz",
|
||||
"integrity": "sha512-xrm3w7SV0Wk+OythZcSbaI8mcr/KHd0knJieu8bVpaPfMv/Agz5EooCAPz3OR5hbYMiUG6dgAPKZKnMzV+3amA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"content-type": "^1.0.5",
|
||||
"debug": "^4.3.4",
|
||||
"file-type": "^16.5.4",
|
||||
"media-typer": "^1.1.0",
|
||||
"strtok3": "^6.3.0",
|
||||
"token-types": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/music-metadata/node_modules/file-type": {
|
||||
"version": "16.5.4",
|
||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-16.5.4.tgz",
|
||||
"integrity": "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"readable-web-to-node-stream": "^3.0.0",
|
||||
"strtok3": "^6.2.4",
|
||||
"token-types": "^4.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sindresorhus/file-type?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/music-metadata/node_modules/media-typer": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
|
||||
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/music-metadata/node_modules/peek-readable": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/peek-readable/-/peek-readable-4.1.0.tgz",
|
||||
"integrity": "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/music-metadata/node_modules/strtok3": {
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-6.3.0.tgz",
|
||||
"integrity": "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"peek-readable": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/music-metadata/node_modules/token-types": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/token-types/-/token-types-4.2.1.tgz",
|
||||
"integrity": "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tokenizer/token": "^0.3.0",
|
||||
"ieee754": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Borewit"
|
||||
}
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.23.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.23.0.tgz",
|
||||
@@ -19240,6 +19331,7 @@
|
||||
"multer": "^2.0.2",
|
||||
"multi-progress": "^4.0.0",
|
||||
"murmurhash": "^2.0.1",
|
||||
"music-metadata": "^7.14.0",
|
||||
"nodemailer": "^6.9.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"openai": "^6.7.0",
|
||||
@@ -20286,6 +20378,7 @@
|
||||
"tools/api-tester": {
|
||||
"name": "@heyputer/puter-api-test",
|
||||
"version": "0.1.0",
|
||||
"extraneous": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"axios": "^1.12.0",
|
||||
@@ -20294,84 +20387,6 @@
|
||||
"yaml": "^2.3.1"
|
||||
}
|
||||
},
|
||||
"tools/api-tester/node_modules/assertion-error": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
|
||||
"integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"tools/api-tester/node_modules/chai": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz",
|
||||
"integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"assertion-error": "^1.1.0",
|
||||
"check-error": "^1.0.3",
|
||||
"deep-eql": "^4.1.3",
|
||||
"get-func-name": "^2.0.2",
|
||||
"loupe": "^2.3.6",
|
||||
"pathval": "^1.1.1",
|
||||
"type-detect": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"tools/api-tester/node_modules/check-error": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz",
|
||||
"integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-func-name": "^2.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"tools/api-tester/node_modules/deep-eql": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz",
|
||||
"integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"type-detect": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"tools/api-tester/node_modules/loupe": {
|
||||
"version": "2.3.7",
|
||||
"resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz",
|
||||
"integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-func-name": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"tools/api-tester/node_modules/pathval": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
|
||||
"integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"tools/api-tester/node_modules/type-detect": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz",
|
||||
"integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"tools/comment-parser": {
|
||||
"version": "1.0.0",
|
||||
"license": "AGPL-3.0-only",
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"multer": "^2.0.2",
|
||||
"multi-progress": "^4.0.0",
|
||||
"murmurhash": "^2.0.1",
|
||||
"music-metadata": "^7.14.0",
|
||||
"nodemailer": "^6.9.3",
|
||||
"on-finished": "^2.4.1",
|
||||
"openai": "^6.7.0",
|
||||
|
||||
@@ -25,6 +25,7 @@ const default_implicit_user_app_permissions = {
|
||||
'driver:puter-image-generation': {},
|
||||
'driver:puter-video-generation': {},
|
||||
'driver:puter-tts': {},
|
||||
'driver:puter-speech2txt': {},
|
||||
'driver:puter-apps': {},
|
||||
'driver:puter-subdomains': {},
|
||||
'driver:temp-email': {},
|
||||
@@ -60,6 +61,8 @@ const implicit_user_app_permissions = [
|
||||
'driver:puter-chat-completion:complete': {},
|
||||
'driver:puter-image-generation:generate': {},
|
||||
'driver:puter-video-generation:generate': {},
|
||||
'driver:puter-speech2txt:transcribe': {},
|
||||
'driver:puter-speech2txt:translate': {},
|
||||
'driver:puter-analytics:create_trace': {},
|
||||
'driver:puter-analytics:record': {},
|
||||
},
|
||||
|
||||
@@ -205,6 +205,50 @@ class AIInterfaceService extends BaseService {
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
col_interfaces.set('puter-speech2txt', {
|
||||
description: 'Speech to text transcription and translation.',
|
||||
methods: {
|
||||
list_models: {
|
||||
description: 'List available speech-to-text models.',
|
||||
result: { type: 'json' },
|
||||
},
|
||||
transcribe: {
|
||||
description: 'Transcribe audio into text.',
|
||||
parameters: {
|
||||
file: { type: 'file' },
|
||||
model: { type: 'string', optional: true },
|
||||
response_format: { type: 'string', optional: true },
|
||||
language: { type: 'string', optional: true },
|
||||
prompt: { type: 'string', optional: true },
|
||||
temperature: { type: 'number', optional: true },
|
||||
logprobs: { type: 'flag', optional: true },
|
||||
timestamp_granularities: { type: 'json', optional: true },
|
||||
stream: { type: 'flag', optional: true },
|
||||
chunking_strategy: { type: 'string', optional: true },
|
||||
known_speaker_names: { type: 'json', optional: true },
|
||||
known_speaker_references: { type: 'json', optional: true },
|
||||
extra_body: { type: 'json', optional: true },
|
||||
},
|
||||
result: { type: 'json' },
|
||||
},
|
||||
translate: {
|
||||
description: 'Translate audio into English text.',
|
||||
parameters: {
|
||||
file: { type: 'file' },
|
||||
model: { type: 'string', optional: true },
|
||||
response_format: { type: 'string', optional: true },
|
||||
prompt: { type: 'string', optional: true },
|
||||
temperature: { type: 'number', optional: true },
|
||||
logprobs: { type: 'flag', optional: true },
|
||||
timestamp_granularities: { type: 'json', optional: true },
|
||||
stream: { type: 'flag', optional: true },
|
||||
extra_body: { type: 'json', optional: true },
|
||||
},
|
||||
result: { type: 'json' },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
const BaseService = require('../../services/BaseService');
|
||||
const APIError = require('../../api/APIError');
|
||||
const { Context } = require('../../util/context');
|
||||
const { FileFacade } = require('../../services/drivers/FileFacade');
|
||||
|
||||
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024; // 25 MB per OpenAI limits
|
||||
const DEFAULT_TRANSCRIBE_MODEL = 'gpt-4o-mini-transcribe';
|
||||
const DEFAULT_TRANSLATE_MODEL = 'whisper-1';
|
||||
const SAMPLE_TRANSCRIPT = {
|
||||
text: 'Hello! This is a sample transcription returned while test mode is enabled.',
|
||||
language: 'en',
|
||||
duration_seconds: 2,
|
||||
words: [
|
||||
{ start: 0.0, end: 0.5, text: 'Hello' },
|
||||
{ start: 0.5, end: 0.9, text: '!' },
|
||||
{ start: 1.1, end: 2.0, text: 'This is a sample transcription.' },
|
||||
],
|
||||
};
|
||||
|
||||
const TRANSCRIPTION_MODEL_CAPABILITIES = {
|
||||
'gpt-4o-mini-transcribe': {
|
||||
canPrompt: true,
|
||||
canLogprobs: true,
|
||||
responseFormats: ['json', 'text'],
|
||||
},
|
||||
'gpt-4o-transcribe': {
|
||||
canPrompt: true,
|
||||
canLogprobs: true,
|
||||
responseFormats: ['json', 'text'],
|
||||
},
|
||||
'gpt-4o-transcribe-diarize': {
|
||||
canPrompt: false,
|
||||
canLogprobs: false,
|
||||
responseFormats: ['json', 'text', 'diarized_json'],
|
||||
requiresChunkingOverThirtySeconds: true,
|
||||
diarization: true,
|
||||
},
|
||||
'whisper-1': {
|
||||
canPrompt: true,
|
||||
canLogprobs: false,
|
||||
responseFormats: ['json', 'text', 'srt', 'verbose_json', 'vtt'],
|
||||
timestampGranularities: true,
|
||||
},
|
||||
};
|
||||
|
||||
class OpenAISpeechToTextService extends BaseService {
|
||||
/** @type {import('../../services/MeteringService/MeteringService').MeteringService} */
|
||||
get meteringService() {
|
||||
return this.services.get('meteringService').meteringService;
|
||||
}
|
||||
|
||||
static MODULES = {
|
||||
openai: require('openai'),
|
||||
musicMetadata: require('music-metadata'),
|
||||
mime: require('mime-types'),
|
||||
path: require('path'),
|
||||
};
|
||||
|
||||
async _init() {
|
||||
let apiKey =
|
||||
this.config?.services?.openai?.apiKey ??
|
||||
this.global_config?.services?.openai?.apiKey;
|
||||
|
||||
if ( !apiKey ) {
|
||||
apiKey =
|
||||
this.config?.openai?.secret_key ??
|
||||
this.global_config.openai?.secret_key;
|
||||
|
||||
if ( apiKey ) {
|
||||
console.warn('The `openai.secret_key` configuration format is deprecated. ' +
|
||||
'Please use `services.openai.apiKey` instead.');
|
||||
}
|
||||
}
|
||||
|
||||
if ( !apiKey ) {
|
||||
throw new Error('OpenAI API key not configured');
|
||||
}
|
||||
|
||||
this.openai = new this.modules.openai.OpenAI({ apiKey });
|
||||
}
|
||||
|
||||
static IMPLEMENTS = {
|
||||
['driver-capabilities']: {
|
||||
supports_test_mode(iface, method_name) {
|
||||
return iface === 'puter-speech2txt' &&
|
||||
(method_name === 'transcribe' || method_name === 'translate');
|
||||
},
|
||||
},
|
||||
['puter-speech2txt']: {
|
||||
async list_models() {
|
||||
return this.listModels();
|
||||
},
|
||||
async transcribe(params) {
|
||||
return this._handleTranscription({ ...params, translate: false });
|
||||
},
|
||||
async translate(params) {
|
||||
return this._handleTranscription({ ...params, translate: true });
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
listModels() {
|
||||
return [
|
||||
{
|
||||
id: 'gpt-4o-mini-transcribe',
|
||||
name: 'GPT-4o mini (Transcribe)',
|
||||
type: 'transcription',
|
||||
response_formats: TRANSCRIPTION_MODEL_CAPABILITIES['gpt-4o-mini-transcribe'].responseFormats,
|
||||
supports_prompt: true,
|
||||
supports_logprobs: true,
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-transcribe',
|
||||
name: 'GPT-4o (Transcribe)',
|
||||
type: 'transcription',
|
||||
response_formats: TRANSCRIPTION_MODEL_CAPABILITIES['gpt-4o-transcribe'].responseFormats,
|
||||
supports_prompt: true,
|
||||
supports_logprobs: true,
|
||||
},
|
||||
{
|
||||
id: 'gpt-4o-transcribe-diarize',
|
||||
name: 'GPT-4o (Transcribe + Diarization)',
|
||||
type: 'transcription',
|
||||
response_formats: TRANSCRIPTION_MODEL_CAPABILITIES['gpt-4o-transcribe-diarize'].responseFormats,
|
||||
supports_prompt: false,
|
||||
supports_logprobs: false,
|
||||
supports_diarization: true,
|
||||
},
|
||||
{
|
||||
id: 'whisper-1',
|
||||
name: 'Whisper 1',
|
||||
type: 'translation',
|
||||
response_formats: TRANSCRIPTION_MODEL_CAPABILITIES['whisper-1'].responseFormats,
|
||||
supports_prompt: true,
|
||||
supports_logprobs: false,
|
||||
supports_timestamp_granularities: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async _handleTranscription({
|
||||
file,
|
||||
translate = false,
|
||||
model,
|
||||
response_format,
|
||||
language,
|
||||
prompt,
|
||||
temperature,
|
||||
logprobs,
|
||||
timestamp_granularities,
|
||||
chunking_strategy,
|
||||
known_speaker_names,
|
||||
known_speaker_references,
|
||||
extra_body,
|
||||
stream,
|
||||
test_mode,
|
||||
}) {
|
||||
if ( test_mode ) {
|
||||
return {
|
||||
...SAMPLE_TRANSCRIPT,
|
||||
model: model || (translate ? DEFAULT_TRANSLATE_MODEL : DEFAULT_TRANSCRIBE_MODEL),
|
||||
};
|
||||
}
|
||||
|
||||
if ( stream ) {
|
||||
throw APIError.create('not_yet_supported', null, {
|
||||
message: 'Streaming transcription is not yet supported.',
|
||||
});
|
||||
}
|
||||
|
||||
if ( !file ) {
|
||||
throw APIError.create('field_missing', null, { key: 'file' });
|
||||
}
|
||||
|
||||
if ( ! (file instanceof FileFacade) ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'file',
|
||||
expected: 'file reference',
|
||||
});
|
||||
}
|
||||
|
||||
const {
|
||||
buffer,
|
||||
filename,
|
||||
mimeType,
|
||||
estimatedSeconds,
|
||||
} = await this._prepareAudioBuffer(file);
|
||||
|
||||
const selectedModel = model || (translate ? DEFAULT_TRANSLATE_MODEL : DEFAULT_TRANSCRIBE_MODEL);
|
||||
const capabilities = TRANSCRIPTION_MODEL_CAPABILITIES[selectedModel];
|
||||
|
||||
if ( !capabilities ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'model',
|
||||
expected: Object.keys(TRANSCRIPTION_MODEL_CAPABILITIES).join(', '),
|
||||
got: selectedModel,
|
||||
});
|
||||
}
|
||||
|
||||
if ( response_format && !capabilities.responseFormats.includes(response_format) ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'response_format',
|
||||
expected: capabilities.responseFormats.join(', '),
|
||||
got: response_format,
|
||||
});
|
||||
}
|
||||
|
||||
if ( prompt && !capabilities.canPrompt ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'prompt',
|
||||
expected: `Not supported for model ${selectedModel}`,
|
||||
});
|
||||
}
|
||||
|
||||
if ( logprobs && !capabilities.canLogprobs ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'logprobs',
|
||||
expected: `Not supported for model ${selectedModel}`,
|
||||
});
|
||||
}
|
||||
|
||||
if ( timestamp_granularities && !capabilities.timestampGranularities ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'timestamp_granularities',
|
||||
expected: `Only supported on models that provide timestamp granularity (such as whisper-1).`,
|
||||
});
|
||||
}
|
||||
|
||||
let diarizationChunkingStrategy = chunking_strategy;
|
||||
if ( capabilities.diarization ) {
|
||||
if ( !response_format ) {
|
||||
response_format = 'diarized_json';
|
||||
}
|
||||
if ( !diarizationChunkingStrategy && capabilities.requiresChunkingOverThirtySeconds && estimatedSeconds > 30 ) {
|
||||
diarizationChunkingStrategy = 'auto';
|
||||
}
|
||||
}
|
||||
|
||||
const actor = Context.get('actor');
|
||||
const usageType = `openai:${selectedModel}:second`;
|
||||
const usageAllowed = await this.meteringService.hasEnoughCreditsFor(actor, usageType, estimatedSeconds);
|
||||
|
||||
if ( !usageAllowed ) {
|
||||
throw APIError.create('insufficient_funds');
|
||||
}
|
||||
|
||||
const openaiFile = await this.modules.openai.toFile(
|
||||
buffer,
|
||||
filename,
|
||||
mimeType ? { type: mimeType } : undefined,
|
||||
);
|
||||
|
||||
const payload = {
|
||||
file: openaiFile,
|
||||
model: selectedModel,
|
||||
};
|
||||
|
||||
if ( response_format ) payload.response_format = response_format;
|
||||
if ( language ) payload.language = language;
|
||||
if ( typeof temperature === 'number' ) payload.temperature = temperature;
|
||||
if ( prompt && capabilities.canPrompt ) payload.prompt = prompt;
|
||||
if ( logprobs && capabilities.canLogprobs ) payload.logprobs = logprobs;
|
||||
if ( timestamp_granularities && capabilities.timestampGranularities ) payload.timestamp_granularities = timestamp_granularities;
|
||||
if ( diarizationChunkingStrategy ) payload.chunking_strategy = diarizationChunkingStrategy;
|
||||
|
||||
if ( capabilities.diarization && (known_speaker_names || known_speaker_references) ) {
|
||||
payload.extra_body = {
|
||||
...(extra_body || {}),
|
||||
...(known_speaker_names ? { known_speaker_names } : {}),
|
||||
...(known_speaker_references ? { known_speaker_references } : {}),
|
||||
};
|
||||
} else if ( extra_body ) {
|
||||
payload.extra_body = extra_body;
|
||||
}
|
||||
|
||||
let transcription;
|
||||
if ( translate ) {
|
||||
transcription = await this.openai.audio.translations.create(payload);
|
||||
} else {
|
||||
transcription = await this.openai.audio.transcriptions.create(payload);
|
||||
}
|
||||
|
||||
this.meteringService.incrementUsage(actor, usageType, estimatedSeconds);
|
||||
|
||||
return this._formatResponse(transcription, response_format);
|
||||
}
|
||||
|
||||
async _prepareAudioBuffer(file) {
|
||||
const buffer = await file.get('buffer');
|
||||
if ( !buffer || !buffer.length ) {
|
||||
throw APIError.create('field_invalid', null, {
|
||||
key: 'file',
|
||||
expected: 'non-empty audio file',
|
||||
});
|
||||
}
|
||||
|
||||
if ( buffer.length > MAX_AUDIO_FILE_SIZE ) {
|
||||
throw APIError.create('file_too_large', null, {
|
||||
max_size: MAX_AUDIO_FILE_SIZE,
|
||||
});
|
||||
}
|
||||
|
||||
let filename = 'audio';
|
||||
let mimeType;
|
||||
|
||||
const pathValue = await file.get('path');
|
||||
if ( pathValue ) {
|
||||
filename = this.modules.path.basename(pathValue);
|
||||
} else {
|
||||
const url = await file.get('web_url');
|
||||
if ( url ) {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const candidate = this.modules.path.basename(parsed.pathname);
|
||||
if ( candidate ) filename = candidate;
|
||||
} catch (_) {
|
||||
// Ignore URL parsing errors; we'll fall back to defaults.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataUrl = await file.get('data_url');
|
||||
if ( dataUrl ) {
|
||||
const match = /^data:([^;,]+)[;,]/.exec(dataUrl);
|
||||
if ( match ) {
|
||||
mimeType = match[1];
|
||||
}
|
||||
}
|
||||
|
||||
if ( !mimeType ) {
|
||||
const guessedMime = this.modules.mime.lookup(filename);
|
||||
if ( guessedMime ) {
|
||||
mimeType = guessedMime;
|
||||
}
|
||||
}
|
||||
|
||||
if ( !filename.includes('.') ) {
|
||||
const extension = mimeType ? this.modules.mime.extension(mimeType) : 'mp3';
|
||||
filename = `${filename}.${extension || 'mp3'}`;
|
||||
}
|
||||
|
||||
let estimatedSeconds = Math.ceil(buffer.length / 16000);
|
||||
try {
|
||||
const metadata = await this.modules.musicMetadata.parseBuffer(buffer, {
|
||||
mimeType,
|
||||
size: buffer.length,
|
||||
});
|
||||
if ( metadata?.format?.duration ) {
|
||||
estimatedSeconds = Math.ceil(metadata.format.duration);
|
||||
}
|
||||
} catch (e) {
|
||||
// When metadata parsing fails we fall back to the byte-size estimate.
|
||||
if ( process.env.DEBUG_AUDIO_METADATA === '1' ) {
|
||||
console.warn('Failed to parse audio metadata for duration estimation:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
estimatedSeconds = Math.max(1, estimatedSeconds);
|
||||
|
||||
return {
|
||||
buffer,
|
||||
filename,
|
||||
mimeType,
|
||||
estimatedSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
_formatResponse(result, response_format) {
|
||||
if ( response_format === 'text' && typeof result === 'string' ) {
|
||||
return result;
|
||||
}
|
||||
if ( typeof result === 'string' ) {
|
||||
return result;
|
||||
}
|
||||
if ( response_format === 'text' && result && typeof result.text === 'string' ) {
|
||||
return result.text;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OpenAISpeechToTextService,
|
||||
};
|
||||
@@ -67,6 +67,9 @@ class PuterAIModule extends AdvancedBase {
|
||||
|
||||
const { OpenAITTSService } = require('./OpenAITTSService');
|
||||
services.registerService('openai-tts', OpenAITTSService);
|
||||
|
||||
const { OpenAISpeechToTextService } = require('./OpenAISpeechToTextService');
|
||||
services.registerService('openai-speech2txt', OpenAISpeechToTextService);
|
||||
}
|
||||
|
||||
if ( config?.services?.claude ) {
|
||||
|
||||
@@ -79,4 +79,10 @@ export const OPENAI_COST_MAP = {
|
||||
'openai:gpt-4o-mini-tts:character': 1500,
|
||||
'openai:tts-1:character': 1500,
|
||||
'openai:tts-1-hd:character': 3000,
|
||||
|
||||
// Speech-to-text models (per second, microcents)
|
||||
'openai:gpt-4o-transcribe:second': 10000,
|
||||
'openai:gpt-4o-mini-transcribe:second': 5000,
|
||||
'openai:gpt-4o-transcribe-diarize:second': 10000,
|
||||
'openai:whisper-1:second': 10000,
|
||||
};
|
||||
|
||||
@@ -289,6 +289,7 @@ class DriverService extends BaseService {
|
||||
const iface_to_driver = {
|
||||
['puter-ocr']: 'aws-textract',
|
||||
['puter-tts']: 'aws-polly',
|
||||
['puter-speech2txt']: 'openai-speech2txt',
|
||||
['puter-chat-completion']: 'openai-completion',
|
||||
['puter-image-generation']: 'openai-image-generation',
|
||||
['puter-video-generation']: 'openai-video-generation',
|
||||
|
||||
Vendored
+31
@@ -59,6 +59,12 @@ interface AI {
|
||||
txt2speech(text: string, language?: string): Promise<HTMLAudioElement>;
|
||||
txt2speech(text: string, language?: string, voice?: string): Promise<HTMLAudioElement>;
|
||||
txt2speech(text: string, language?: string, voice?: string, engine?: string): Promise<HTMLAudioElement>;
|
||||
|
||||
speech2txt(source: string | File | Blob): Promise<string | Speech2TxtResult>;
|
||||
speech2txt(source: string | File | Blob, options?: Speech2TxtOptions): Promise<string | Speech2TxtResult>;
|
||||
speech2txt(options: Speech2TxtOptions): Promise<string | Speech2TxtResult>;
|
||||
speech2txt(source: string | File | Blob, testMode?: boolean): Promise<string | Speech2TxtResult>;
|
||||
speech2txt(source: Speech2TxtOptions, testMode?: boolean): Promise<string | Speech2TxtResult>;
|
||||
}
|
||||
|
||||
type StreamingChatOptions = Omit<ChatOptions, "stream"> & { stream: true };
|
||||
@@ -136,6 +142,31 @@ interface Txt2SpeechOptions {
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
interface Speech2TxtOptions {
|
||||
file?: string | File | Blob;
|
||||
audio?: string | File | Blob;
|
||||
model?: 'gpt-4o-mini-transcribe' | 'gpt-4o-transcribe' | 'gpt-4o-transcribe-diarize' | 'whisper-1' | string;
|
||||
response_format?: 'json' | 'text' | 'diarized_json' | 'srt' | 'verbose_json' | 'vtt' | string;
|
||||
language?: string;
|
||||
prompt?: string;
|
||||
temperature?: number;
|
||||
logprobs?: boolean;
|
||||
timestamp_granularities?: string[];
|
||||
translate?: boolean;
|
||||
stream?: boolean;
|
||||
chunking_strategy?: string;
|
||||
known_speaker_names?: string[];
|
||||
known_speaker_references?: string[];
|
||||
extra_body?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface Speech2TxtResult {
|
||||
text?: string;
|
||||
language?: string;
|
||||
segments?: Array<Record<string, unknown>>;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface ChatResponseChunk {
|
||||
text?: string;
|
||||
[key: string]: any;
|
||||
|
||||
@@ -274,6 +274,78 @@ class AI{
|
||||
}).call(this, options);
|
||||
}
|
||||
|
||||
speech2txt = async (...args) => {
|
||||
const MAX_INPUT_SIZE = 25 * 1024 * 1024;
|
||||
if ( !args || !args.length ) {
|
||||
throw ({ message: 'Arguments are required', code: 'arguments_required' });
|
||||
}
|
||||
|
||||
const normalizeSource = async (value) => {
|
||||
if ( value instanceof Blob ) {
|
||||
return await utils.blobToDataUri(value);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
let options = {};
|
||||
let testMode = false;
|
||||
|
||||
const primary = args[0];
|
||||
if ( primary && typeof primary === 'object' && !Array.isArray(primary) && !(primary instanceof Blob) ) {
|
||||
options = { ...primary };
|
||||
} else {
|
||||
options.file = await normalizeSource(primary);
|
||||
}
|
||||
|
||||
if ( args[1] && typeof args[1] === 'object' && !Array.isArray(args[1]) && !(args[1] instanceof Blob) ) {
|
||||
options = { ...options, ...args[1] };
|
||||
} else if ( typeof args[1] === 'boolean' ) {
|
||||
testMode = args[1];
|
||||
}
|
||||
|
||||
if ( typeof args[2] === 'boolean' ) {
|
||||
testMode = args[2];
|
||||
}
|
||||
|
||||
if ( options.audio ) {
|
||||
options.file = await normalizeSource(options.audio);
|
||||
delete options.audio;
|
||||
}
|
||||
|
||||
if ( options.file instanceof Blob ) {
|
||||
options.file = await normalizeSource(options.file);
|
||||
}
|
||||
|
||||
if ( !options.file ) {
|
||||
throw { message: 'Audio input is required', code: 'audio_required' };
|
||||
}
|
||||
|
||||
if ( typeof options.file === 'string' && options.file.startsWith('data:') ) {
|
||||
const base64 = options.file.split(',')[1] || '';
|
||||
const padding = base64.endsWith('==') ? 2 : (base64.endsWith('=') ? 1 : 0);
|
||||
const byteLength = Math.floor((base64.length * 3) / 4) - padding;
|
||||
if ( byteLength > MAX_INPUT_SIZE ) {
|
||||
throw { message: 'Input size cannot be larger than 25 MB', code: 'input_too_large' };
|
||||
}
|
||||
}
|
||||
|
||||
const driverMethod = options.translate ? 'translate' : 'transcribe';
|
||||
const driverArgs = { ...options };
|
||||
delete driverArgs.translate;
|
||||
|
||||
const responseFormat = driverArgs.response_format;
|
||||
|
||||
return await utils.make_driver_method([], 'puter-speech2txt', 'openai-speech2txt', driverMethod, {
|
||||
test_mode: testMode,
|
||||
transform: async (result) => {
|
||||
if ( responseFormat === 'text' && result && typeof result === 'object' && typeof result.text === 'string' ) {
|
||||
return result.text;
|
||||
}
|
||||
return result;
|
||||
},
|
||||
}).call(this, driverArgs);
|
||||
}
|
||||
|
||||
// Add new methods for TTS engine management
|
||||
txt2speech = Object.assign(this.txt2speech, {
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user