Kunne du tenkt deg å lage en lyd-versjon av bloggposten din? Uten å måtte prate for mye selv? La oss bruke generativ AI til å lage en podcast av artikkelen din!
Hvis du titter helt øverst i denne artikkelen, så ser du en liten play-knapp. Om du trykker på den, så får du lest opp denne teksten med en ganske god innlevelse. Sure, det er ikke helt perfekt, men det er et fullgodt alternativ til å faktisk lese. Og det kan jo være nyttig når man er på farten, tar seg en treningstur eller bare foretrekker å høre over å lese.
Text-to-speech har også gjort underverker for universell utforming. Plutselig er lange, kompliserte tekster mye mer tilgjengelig for både folk med dårlig syn og nedsatte leseferdigheter.
Men det å generere lyd fra tekst høres jo ikke trivielt ut. Hvordan gjør man det egentlig? I denne artikkelen kommer vi til å se på hvordan vi har implementert denne featuren i Bekk Christmas, samt hvilke utfordringer vi møtte på underveis.
Sett opp integrasjonen din
Det har kommet mange forskjellige tjenester som lar deg sende inn tekst, og få tilbake en lydfil. Google, Amazon og OpenAI tilbyr hver sin tjeneste som fungerer ganske likt for denne typen behov. Vi gikk for OpenAI sin text-to-speech-tjeneste i denne implementasjonen – men jeg antar at de andre tjeneste også tilbyr ganske tilsvarende APIer.
Det første vi trenger å gjøre er å lage en API-nøkkel på openai.com. Hvis du har laget deg en konto alt, kan du gå til https://platform.openai.com/settings/organization/api-keys og opprette en. Lim den inn i .env filen din:
OPENAI_API_KEY="<min api-nøkkel>"
Den neste vi må gjøre er å installerere npm-pakken til Open AI:
npm install openai
Så må vi lage et endepunkt som vi kan hente lyden vår fra. Bekk Christmas er implementert som en Remix-app, og vi lager oss derfor en ny fil routes/api.tts.ts
:
import { LoaderFunctionArgs } from '@remix-run/node'
import OpenAI from 'openai'
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
})
export const loader = ({ params }: LoaderFunctionArgs) => {
// TODO: Her har vi en jobb å gjøre
return new Response(200)
}
Her lager vi en ny OpenAI-klient ved å sende inn nøkkelen vår, og oppretter en såkalt "resource route" – eller en route-fil som kun har en loader og/eller action-eksport.
Vi valgte å hente innholdet vi ønsker å gjøre om til tekst via en ID som vi sender inn. Det ser sånn ca slik ut:
import { LoaderFunctionArgs } from '@remix-run/node'
import { contentClient } from "api/content/client" // eller et annet sted
// ...
export const loader = async ({ params }: LoaderFunctionArgs) => {
const url = new URL(request.url)
const id = url.searchParams.get('id')
if (!id) {
throw new Response('Missing id parameter', { status: 400 })
}
const content = await contentClient.getById(id);
if (!content) {
throw new Response('Content not found', { status: 404 })
}
// TODO: Nå er vi klare for å gjøre om innholdet til lyd!
return new Response(200)
}
Her sjekker vi om vi har en get-parameter som heter ID, og om vi finner innholdet i innholdsdatabasen vår (i vårt tilfelle Sanity). Hvis ikke får du en feilmelding.
Men hvordan ser selve lyd-genereringen ut? Jo – slik:
// Sett sammen strengen man ønsker å les eopp
const input = `${content.title}\n\n${content.description}\n\n ${content.body}`;
// Få tilbake en respons fra open ai
const mp3 = await openai.audio.speech.create({
model: 'tts-1',
voice: 'shimmer',
input,
})
// Gjør om responsen til en strøm
const audioStream = await mp3.arrayBuffer()
// Returner denne strømmen etterhvert som vi får den inn
return new Response(audioStream, {
headers: {
'Content-Type': 'audio/mpeg',
'Transfer-Encoding': 'chunked',
},
})
Hvis du fikk litt sånn "draw the rest of the owl"-vibber her, så er det helt okei – vi kan gå gjennom det steg for steg.
Det første vi gjør er å sette sammen strengen vi ønsker å ha lest opp. Her satt vi sammen en tekst med tittelen til artikkelen, ingressen og så teksten. Man kunne godt tatt med navnet på forfatteren, eller andre ting. Vi legger også til to linjeskift etter hver bit – det får den genererte stemmen til å ta en liten pause etter hver av dem.
Men nå må vi faktisk generere litt lyd da! Det er egentlig ganske rett frem med OpenAI sin SDK – du definerer hvilken modell du vil bruke (tts-1 var det som var default, men det finnes også en HD-modell om man ønsker det), hvilken stemme du vil bruke (vi valgte "shimmer", men det er seks forskjellige å velge mellom), og så sender du inn innholdet du vil gjøre om til lyd. Enkelt og greit.
Det du får tilbake igjen er en ganske vanlig Response-objekt, som har en audioBuffer
funksjon. Den returnerer en strøm med lyd-bytes, som vi så returnerer tilbake til brukeren vår med noen headers som forteller browseren at det er en lydstrøm.
Lag et brukergrensesnitt
Så nå har vi et endepunkt – men funker det egentlig? La oss lage et brukergrensesnitt som lar oss spille det av! Heldigvis får vi noe som funker fint ut av boksen i moderne nettlesere:
<audio controls>
<source src={`/api/tts?id=${post.id}`} type="audio/mpeg" />
</audio>
Da får du noe som ser ca slik ut:
Og trykker du på play-knappen, så bruker den litt tid på å laste, før den faktisk spiller av lyden din! Det føles faktisk litt magisk.
Løs lengdebegrensninger
Men om du prøver å sende inn en hel bloggpost, møter du fort på en ganske vond begrensning – OpenAI støtter kun tekster opp mot 4096 tegn! Hvordan kan vi jobbe oss rundt det?
En veldig enkel løsning kan være å bare trimme innholdet til å være maks 4096 tegn langt. Fort og gæli, men du får i alle fall deler av teksten din lest opp.
const mp3 = await openai.audio.speech.create({
model: 'tts-1',
voice: 'shimmer',
input: content.slice(0, 4096),
})
Men vi burde jo klare bedre enn det! Løsningen vi kom frem til var å dele opp teksten vår i bolker på maks 4096 tegn, og spørre OpenAI for hver av disse bolkene. Så satt vi dem sammen til én strøm, og returnerte det til brukeren.
Her er metoden som deler opp innholdet i velstrukturerte bolker. Vi deler opp teksten i setninger istedenfor ord, så vi ikke deler opp teksten på ulogiske måter.
function chunkText(text: string, chunkSize: number = 4096): string[] {
const chunks: string[] = []
let currentChunk = ''
const sentences = text.split('. ')
for (const sentence of sentences) {
if ((currentChunk + sentence).length > chunkSize) {
chunks.push(currentChunk)
currentChunk = sentence
} else {
currentChunk += (currentChunk ? '. ' : '') + sentence
}
}
if (currentChunk) {
chunks.push(currentChunk)
}
return chunks
}
Det neste vi må gjøre er å lage en strøm som vi kan legge til responsene fra etterhvert som de kommer inn. Der har webplattformen et nyttig API som heter ReadableStream, som lar oss gjøre nettopp det.
const textChunks = chunkText(text)
const stream = new ReadableStream({
async start(controller) {
for (const chunk of textChunks) {
// Her lager vi lyd for hver chunk, en etter en
const mp3 = await openai.audio.speech.create({
model: 'tts-1',
voice: 'shimmer',
input: chunk,
})
const audioBuffer = await mp3.arrayBuffer()
// Når vi har fått responsen vår, legger vi den til strømmen
controller.enqueue(new Uint8Array(audioBuffer))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'audio/mpeg',
'Transfer-Encoding': 'chunked',
},
})
Her looper vi gjennom hver tekst-chunk, henter den genererte lyden for hver chunk, og legger den til en strøm. Så returnerer vi den strømmen til brukeren. Merk at vi ikke lenger setter "Content-Length" headeren lenger – for nå returnerer vi en strøm av data vi ikke vet hvor lang er før hele er mottatt.
Responsen blir sendt så snart første "chunk" er ferdig prosessert, og så legges det til flere og flere lydsegmenter i strømmen etterhvert som de blir ferdige med prosesseringen. Ganske gøy!
Få ned lastetiden
Men selv om strømmen vår nå kan være så lang som bare det, så er den initielle lastetiden fortsatt ganske lang. Det er jo forsåvidt logisk nok, siden vi sender ganske lange tekster til OpenAI. Men det er det jo ingen grunn til! La oss dra ned chunk-størrelsen til noe litt mindre - f.eks. 500 tegn:
function chunkText(text: string, chunkSize: number = 500) {
// som før
}
Nå starter strømmen mye, mye raskere (på under ett sekund), og vi får fortsatt prosessert lange tekster. Om man vil spare litt kall her, så kan man la den første chunken være liten, og påfølgende chunks bli større, men så fancy gadd vi ikke å lage det.
Og der har du det – en komplett text-to-speech feature som fungerer ut av boksen.
Ulempen med denne tilnærmingen er jo at den kan bli litt dyr – i alle fall om den blir godt brukt. Vi lager jo tross alt en ny lydfil for hver bruker! Derfor kan vi heller generere denne lydfilen hver gang artikkelen endres, og laste den opp på en eller annen filtjeneste. Men enn så lenge har ikke dette blitt dyrere enn 10-20 kroner om dagen for Bekk Christmas sin del, så det har ikke vært noe problem.
I vår løsning la vi også på stemme-gjetting basert på navn. Har du et kvinnelig fornavn får du en kvinnelig stemme, og tilsvarende for menn. Og om du føler at ingen av dem er helt deg, så kan man overstyre via CMSet. Ganske snedig, egentlig!
Test det ut med din egen løsning, og si hva du synes.