Skip to content

Commit 0b183de

Browse files
committed
init
0 parents  commit 0b183de

11 files changed

Lines changed: 3186 additions & 0 deletions

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
node_modules
2+
dist

.woodpecker/buildRelease.sh

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/bin/bash
2+
3+
npm run build 2>&1 | tee build.log
4+
build_status=${PIPESTATUS[0]}
5+
6+
if [ $build_status -ne 0 ]; then
7+
echo "Build failed. Exiting with status code $build_status"
8+
exit $build_status
9+
fi

.woodpecker/buildSlackNotify.sh

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
#!/bin/sh
2+
3+
set -x
4+
5+
COMMIT_SHORT_SHA=$(echo $CI_COMMIT_SHA | cut -c1-8)
6+
7+
STATUS=${1}
8+
9+
if [ "$STATUS" = "success" ]; then
10+
MESSAGE="Did a build without issues on \`$CI_REPO_NAME/$CI_COMMIT_BRANCH\`. Commit: _${CI_COMMIT_MESSAGE}_ (<$CI_COMMIT_URL|$COMMIT_SHORT_SHA>)"
11+
12+
curl -s -X POST -H "Content-Type: application/json" -d '{
13+
"username": "'"$CI_COMMIT_AUTHOR"'",
14+
"icon_url": "'"$CI_COMMIT_AUTHOR_AVATAR"'",
15+
"attachments": [
16+
{
17+
"mrkdwn_in": ["text", "pretext"],
18+
"color": "#36a64f",
19+
"text": "'"$MESSAGE"'"
20+
}
21+
]
22+
}' "$DEVELOPERS_SLACK_WEBHOOK"
23+
exit 0
24+
fi
25+
export BUILD_LOG=$(cat ./build.log)
26+
27+
BUILD_LOG=$(echo $BUILD_LOG | sed 's/"/\\"/g')
28+
29+
MESSAGE="Broke \`$CI_REPO_NAME/$CI_COMMIT_BRANCH\` with commit _${CI_COMMIT_MESSAGE}_ (<$CI_COMMIT_URL|$COMMIT_SHORT_SHA>)"
30+
CODE_BLOCK="\`\`\`$BUILD_LOG\n\`\`\`"
31+
32+
echo "Sending slack message to developers $MESSAGE"
33+
curl -sS -X POST -H "Content-Type: application/json" -d '{
34+
"username": "'"$CI_COMMIT_AUTHOR"'",
35+
"icon_url": "'"$CI_COMMIT_AUTHOR_AVATAR"'",
36+
"attachments": [
37+
{
38+
"mrkdwn_in": ["text", "pretext"],
39+
"color": "#8A1C12",
40+
"text": "'"$CODE_BLOCK"'",
41+
"pretext": "'"$MESSAGE"'"
42+
}
43+
]
44+
}' "$DEVELOPERS_SLACK_WEBHOOK" 2>&1

.woodpecker/release.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
clone:
2+
git:
3+
image: woodpeckerci/plugin-git
4+
settings:
5+
partial: false
6+
depth: 5
7+
8+
steps:
9+
init-secrets:
10+
when:
11+
- event: push
12+
image: infisical/cli
13+
environment:
14+
INFISICAL_TOKEN:
15+
from_secret: VAULT_TOKEN
16+
commands:
17+
- infisical export --domain https://vault.devforth.io/api --format=dotenv-export --env="prod" > /woodpecker/deploy.vault.env
18+
19+
build:
20+
image: devforth/node20-pnpm:latest
21+
when:
22+
- event: push
23+
commands:
24+
- . /woodpecker/deploy.vault.env
25+
- pnpm install
26+
- /bin/bash ./.woodpecker/buildRelease.sh
27+
- npm audit signatures
28+
29+
release:
30+
image: devforth/node20-pnpm:latest
31+
when:
32+
- event:
33+
- push
34+
branch:
35+
- main
36+
commands:
37+
- . /woodpecker/deploy.vault.env
38+
- pnpm exec semantic-release
39+
40+
slack-on-failure:
41+
image: curlimages/curl
42+
when:
43+
- event: push
44+
status: [failure]
45+
commands:
46+
- . /woodpecker/deploy.vault.env
47+
- /bin/sh ./.woodpecker/buildSlackNotify.sh failure
48+
49+
slack-on-success:
50+
image: curlimages/curl
51+
when:
52+
- event: push
53+
status: [success]
54+
commands:
55+
- . /woodpecker/deploy.vault.env
56+
- /bin/sh ./.woodpecker/buildSlackNotify.sh success

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 DevForth
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

OpenAIAudioAdapter.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import OpenAI, { toFile } from "openai";
2+
import type {
3+
OpenAITtsVoice,
4+
SpeechToTextAdapter,
5+
SpeechToTextInput,
6+
SpeechToTextResult,
7+
TextToSpeechAdapter,
8+
TextToSpeechInput,
9+
TextToSpeechResult,
10+
TextToSpeechStreamInput,
11+
TextToSpeechStreamResult,
12+
TtsAudioFormat,
13+
} from "./audioAdapters.js";
14+
15+
export type OpenAITranscriptionModel =
16+
| "gpt-4o-transcribe"
17+
| "gpt-4o-mini-transcribe"
18+
| "whisper-1";
19+
20+
export type OpenAISpeechModel =
21+
| "gpt-4o-mini-tts"
22+
| "tts-1"
23+
| "tts-1-hd";
24+
25+
export type OpenAIAudioAdapterOptions = {
26+
client?: OpenAI;
27+
apiKey?: string;
28+
transcriptionModel?: OpenAITranscriptionModel;
29+
speechModel?: OpenAISpeechModel;
30+
defaultVoice?: OpenAITtsVoice;
31+
defaultAudioFormat?: TtsAudioFormat;
32+
maxAudioFileSizeBytes?: number;
33+
};
34+
35+
const DEFAULT_MAX_AUDIO_FILE_SIZE_BYTES = 25 * 1024 * 1024;
36+
37+
const AUDIO_MIME_TYPES = new Set([
38+
"audio/mpeg",
39+
"audio/mp3",
40+
"audio/mp4",
41+
"audio/mpga",
42+
"audio/m4a",
43+
"audio/wav",
44+
"audio/wave",
45+
"audio/webm",
46+
"audio/ogg",
47+
"audio/flac",
48+
"video/mp4",
49+
"video/webm",
50+
]);
51+
52+
const MIME_TYPE_BY_TTS_FORMAT: Record<TtsAudioFormat, string> = {
53+
mp3: "audio/mpeg",
54+
opus: "audio/opus",
55+
aac: "audio/aac",
56+
flac: "audio/flac",
57+
wav: "audio/wav",
58+
pcm: "audio/pcm",
59+
};
60+
61+
export class OpenAIAudioAdapter
62+
implements SpeechToTextAdapter, TextToSpeechAdapter
63+
{
64+
public readonly name = "openai";
65+
66+
private readonly client: OpenAI;
67+
private readonly transcriptionModel: OpenAITranscriptionModel;
68+
private readonly speechModel: OpenAISpeechModel;
69+
private readonly defaultVoice: OpenAITtsVoice;
70+
private readonly defaultAudioFormat: TtsAudioFormat;
71+
private readonly maxAudioFileSizeBytes: number;
72+
private readonly apiKey?: string;
73+
private readonly hasCustomClient: boolean;
74+
75+
constructor(options: OpenAIAudioAdapterOptions = {}) {
76+
this.apiKey = options.apiKey ?? process.env.OPENAI_API_KEY;
77+
this.hasCustomClient = Boolean(options.client);
78+
this.client =
79+
options.client ??
80+
new OpenAI({
81+
apiKey: this.apiKey,
82+
});
83+
this.transcriptionModel =
84+
options.transcriptionModel ?? "gpt-4o-mini-transcribe";
85+
this.speechModel = options.speechModel ?? "gpt-4o-mini-tts";
86+
this.defaultVoice = options.defaultVoice ?? "coral";
87+
this.defaultAudioFormat = options.defaultAudioFormat ?? "mp3";
88+
this.maxAudioFileSizeBytes =
89+
options.maxAudioFileSizeBytes ?? DEFAULT_MAX_AUDIO_FILE_SIZE_BYTES;
90+
}
91+
92+
validate(): void {
93+
if (!this.hasCustomClient && !this.apiKey) {
94+
throw new Error("OpenAI API key is required");
95+
}
96+
}
97+
98+
async transcribe(input: SpeechToTextInput): Promise<SpeechToTextResult> {
99+
this.validateAudioInput(input);
100+
101+
const file = await toFile(input.buffer, input.filename, {
102+
type: input.mimeType,
103+
});
104+
const language = input.language === "auto" ? undefined : input.language;
105+
const result = await this.client.audio.transcriptions.create({
106+
model: this.transcriptionModel,
107+
file,
108+
language,
109+
prompt: input.prompt,
110+
response_format: "json",
111+
});
112+
113+
return {
114+
text: result.text.trim(),
115+
raw: result,
116+
};
117+
}
118+
119+
async synthesize(input: TextToSpeechStreamInput): Promise<TextToSpeechStreamResult>;
120+
async synthesize(input: TextToSpeechInput): Promise<TextToSpeechResult>;
121+
async synthesize(
122+
input: TextToSpeechInput | TextToSpeechStreamInput,
123+
): Promise<TextToSpeechResult | TextToSpeechStreamResult> {
124+
const text = input.text.trim();
125+
126+
if (!text) {
127+
throw new Error("TTS input text is empty");
128+
}
129+
130+
const format = input.format ?? this.defaultAudioFormat;
131+
const streamFormat = input.stream ? input.streamFormat ?? "audio" : undefined;
132+
const response = await this.client.audio.speech.create({
133+
model: this.speechModel,
134+
voice: input.voice ?? this.defaultVoice,
135+
input: text,
136+
response_format: format,
137+
speed: input.speed,
138+
instructions: input.instructions,
139+
stream_format: streamFormat,
140+
});
141+
142+
if (input.stream) {
143+
const selectedStreamFormat = streamFormat ?? "audio";
144+
145+
if (!response.body) {
146+
throw new Error("TTS stream response body is empty");
147+
}
148+
149+
return {
150+
audioStream: response.body,
151+
mimeType:
152+
selectedStreamFormat === "sse" ? "text/event-stream" : MIME_TYPE_BY_TTS_FORMAT[format],
153+
format,
154+
streamFormat: selectedStreamFormat,
155+
raw: response,
156+
};
157+
}
158+
159+
const audio = Buffer.from(await response.arrayBuffer());
160+
161+
return {
162+
audio,
163+
mimeType: MIME_TYPE_BY_TTS_FORMAT[format],
164+
format,
165+
raw: response,
166+
};
167+
}
168+
169+
private validateAudioInput(input: SpeechToTextInput): void {
170+
if (!input.buffer.length) {
171+
throw new Error("Audio buffer is empty");
172+
}
173+
174+
if (input.buffer.length > this.maxAudioFileSizeBytes) {
175+
throw new Error(
176+
`Audio file is too large. Maximum size is ${this.maxAudioFileSizeBytes} bytes`,
177+
);
178+
}
179+
180+
if (!input.filename) {
181+
throw new Error("Audio filename is required");
182+
}
183+
184+
if (!input.mimeType) {
185+
throw new Error("Audio MIME type is required");
186+
}
187+
188+
if (!AUDIO_MIME_TYPES.has(input.mimeType)) {
189+
throw new Error(`Unsupported audio MIME type: ${input.mimeType}`);
190+
}
191+
}
192+
}
193+
194+
export default OpenAIAudioAdapter;

0 commit comments

Comments
 (0)