Reaaliaikainen litteraatti saapuu kahdesti: ensin luonnoksena, joka muuttuu koko ajan, sitten lopullisena rivinä, joka ei muutu. Piirrä luonnos korvattavana häntänä ja lopullinen rivi historiana, ja loput live-tekstinäkymästä on pelkkää putkistotyötä.
Reaaliaikainen istunto on pienin asia, jonka VocalStack API tekee. Palvelimesi tekee yhden HTTP-kutsun ja saa takaisin allekirjoitetun streamUrl-osoitteen. Asiakkaasi avaa WebSocket-yhteyden tähän osoitteeseen, lähettää raakaa ääntä sitä mukaa kuin sitä tallennetaan ja lukee litteraatin takaisin JSON-tekstikehyksinä, jotka tulevat puhujan perässä hetkiä myöhemmin. Kun ääni loppuu, lähetät eof-viestin. Palvelin tyhjentää puskurinsa, raportoi istunnon mittaukset ja sulkee yhteyden.
Mitään ei tallenneta. Litteraatti on olemassa vain siirrossa, ja juuri sitä tekstitysnäkymä, kokouksen peittokuva tai äänitoiminto omassa sovelluksessasi tarvitsee. Jos haluat sanat talteen, jaettavaksi ja vietäväksi myöhemmin, tarvitset suoran litteroinnin ja lähetyksen; viimeinen osio kertoo, mihin suuntaan mennä.
Muoto on sama, jonka useimmat suoratoistavat puhe-API:t jakavat: HTTP-kutsu valtuutusta varten, pitkäkestoinen yhteys, jossa ääni menee sisään ja JSON tulee ulos, sekä tapa erottaa alustava teksti lopullisesta. Nimet vaihtelevat API:sta toiseen, mutta osat harvoin muuttuvat. Tässä ne järjestyksessä:
POST /v1/realtime/sessions API-avaimellasi palvelimellasi. Vastaus on { sessionId, streamUrl, expiresAt }.streamUrl.session.loading-sykkeitä, kunnes saat session.ready-viestin.transcript.draft (korvaa hännän) ja transcript.committed (lisää lopullinen rivi).{"type":"eof"}; lue session.done.Vain vaihe 1 tarvitsee avaimesi, joten se tapahtuu palvelimellasi.
Luo avain kohdassa Kehittäjät, API-avaimet oikeudella Write. Avain on tarkoitettu palvelimille ja skripteille: API hylkää avaimen, joka lähetetään verkkosivulta tai URL-osoitteessa, joten luot istunnon oman reittisi takana, ja sivu näkee vain streamUrl-osoitteen.
URL kantaa mukanaan oman tunnisteensa selaimen rajoituksen vuoksi, ja se kannattaa tuntea yleisesti. Selaimen WebSocket-konstruktori ei voi asettaa pyyntöotsikoita, joten avainta ei voi laittaa Authorization-otsikkoon. Jokainen selaimelle tarkoitettu puhe-API todentaa yhteytensä lyhytikäisellä tunnisteella URL-osoitteessa (tai aliprotokollan nimessä). Lyhytikäisyys on olennaista: URL päätyy lokeihin ja historiaan, joten sen sisällön on vanhennuttava. Yhdistä ennen expiresAt-hetkeä.
SDK:t ladataan Kehittäjät-alueelta, ei paketinhallinnasta. Pika-aloituksen asennusosio näyttää tämän ympäristön omat komennot. TypeScript-SDK:lla palvelinreitti näyttää tältä:
// On your server, behind a route of yours (POST /api/transcribe, say):
import { VocalStack } from '@vocalstack/sdk';
const vs = new VocalStack({ baseUrl: '{{api}}', apiKey: process.env.VOCALSTACK_API_KEY });
export async function createStream(): Promise<{ sessionId: string; streamUrl: string }> {
const { sessionId, streamUrl } = await vs.realtime.createSession({ spokenLanguage: 'en' });
return { sessionId, streamUrl };
}
Ilman SDK:ta tarvitaan yksi fetch. Tämä pyytää myös espanjaa ja kytkee lauseiden kokoamisen pois, vastaten alempana olevaa protokollaesimerkkiä:
const res = await fetch('{{api}}/v1/realtime/sessions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.VOCALSTACK_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ spokenLanguage: 'en', translate: ['es'], sentences: { enabled: false } }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`); // the error envelope: { code, message, … }
const { streamUrl } = (await res.json()) as { sessionId: string; streamUrl: string; expiresAt: string };
Jokaisella asetuksella on oletusarvo. Alkuun kannattaa tuntea spokenLanguage (jos jätät sen pois, kieli tunnistetaan automaattisesti), translate (kohdekielten lista), speakerLabels ja latencyMode; muut tulevat esiin tarpeen mukaan alla.
Anna streamUrl sille, joka tallentaa äänen; tästä eteenpäin mitään avainta ei tarvita.
Yhdistä streamUrl-osoitteeseen sellaisenaan, ilman otsikkoa. Istunnon käynnistyessä palvelin lähettää session.loading-sykkeitä, ja sitten tulee session.ready. Älä lähetä ääntä ennen sitä. Aiemmin lähetettyä ääntä ei säilytetä myöhempää käyttöä varten; palvelin pitää sitä virheenä, lähettää error-kehyksen ja sulkee yhteyden.
Kun yhteys on auki, jokainen virhe on tuon kehyksen muotoinen: {"type":"error","code":"…","message":"…","retryable":true|false}. code on suljettu lista (invalid_audio, invalid_options, invalid_token, capacity_exceeded, session_expired, slow_consumer, engine_unavailable, internal), message on tarkoitettu lokeihisi, ja retryable kertoo, kannattaako uusi istunto yrittää. Käsittele ne ensimmäisestä kehyksestä lähtien:
const ws = new WebSocket(streamUrl); // the token is in the URL: no header
ws.binaryType = 'arraybuffer';
ws.addEventListener('message', (m: MessageEvent) => {
if (typeof m.data !== 'string') return;
const f = JSON.parse(m.data);
switch (f.type) {
case 'session.loading':
console.error('starting…');
break;
case 'session.ready':
startMicrophone(); // send audio only after session.ready
break;
case 'error':
console.error(`${f.code}: ${f.message}`);
if (f.retryable) scheduleReconnect(); // a new session, with backoff; see "Stop"
break;
}
});
Jos käytät raakaa asiakasta, sinun on hoidettava itse kaksi asiaa; SDK:t hoitavat ne jo. Ensimmäinen: hylätty yhteydenmuodostus tuo mukanaan API:n tavallisen HTTP-virhevastauksen: 401 invalid_token, 410 session_expired tai 503 unavailable, jonka mukana tulee kunnioitettava Retry-After-otsikko.
Toinen: ruuhkautuminen näyttää hiljaisuudelta, ei virheeltä. Jos yksikään kehys ei saavu 30 sekunnissa, käsittele se tilanteena capacity_exceeded ja yhdistä uudelleen; uusi istunto yleensä onnistuu. Jos latauskehyksiä tulee, mutta session.ready ei koskaan tule, käsittele se tilanteena engine_unavailable, joka on myös uudelleen yritettävissä. SDK:issa RealtimeSession.connect palautuu session.ready-viestissä ja epäonnistuu readyDeadlineMs-ajan kuluttua oikealla koodilla.
Kun session.ready on tullut, palvelin odottaa ääntä siinä tahdissa, jolla se puhutaan.
Ääni menee sisään binäärisinä kehyksinä raakana PCM-datana: 32-bittinen liukuluku, little-endian, 16 kHz, mono (pcm_f32le, istunnon oletusarvoinen audio-muoto). Lähetä se sitä mukaa kuin se tallennetaan, noin 100–250 ms paloissa, odottamatta litteraattia palojen välissä.
Tavumatematiikka kannattaa tehdä kerran. 16 000 näytettä sekunnissa, 4 tavua kutakin, on 64 000 tavua sekunnissa, joten 100 ms on 6 400 tavua ja 250 ms on 16 000. Puhemallit on yleensä koulutettu 16 kHz monolla, minkä vuoksi niin moni reaaliaikainen API pyytää sitä, kun taas mikrofonit ja selaimet tallentavat 44,1 tai 48 kHz:llä ja usein stereona. Jonkin osan järjestelmästä on muutettava näytteenopeus ja yhdistettävä kanavat yhdeksi ennen kuin tavut lähtevät: tässä se on ffmpeg, selaimessa SDK:n mikrofoniapuri. 48 kHz:n äänen lähettäminen ja 16 kHz:n ilmoittaminen ei kaadu; se litteroi hidastettua puhetta, ja huonosti.
Node.js:ssä ffmpeg on yksinkertaisin tallennuslaite:
import { spawn } from 'node:child_process';
function startMicrophone(): void {
// Raw PCM: f32le, 16 kHz, mono, as binary frames. macOS: -f avfoundation -i :0, Linux: -f pulse -i default.
const mic = spawn('ffmpeg', ['-loglevel', 'error', '-f', 'avfoundation', '-i', ':0', '-f', 'f32le', '-ac', '1', '-ar', '16000', '-']);
mic.stdout.on('data', (chunk: Buffer) => {
// Backpressure: bytes the socket has accepted but not yet put on the wire.
// Two seconds of audio (128,000 bytes) queued means the link, not the server, is behind.
if (ws.bufferedAmount > 128_000) console.error(`link behind by ${ws.bufferedAmount} bytes`);
ws.send(new Uint8Array(chunk));
});
mic.on('close', () => ws.send(JSON.stringify({ type: 'eof' })));
process.once('SIGINT', () => mic.kill());
}
ws.send ei koskaan blokkaa; se jonottaa. bufferedAmount on tuon jonon koko, ja terveellä yhteydellä se pysyy lähellä nollaa. Live-ääntä ei voi pysäyttää, joten kasvavan jonon oikea reaktio on huomata se, ei pudottaa kehyksiä. Myös palvelin soveltaa takapainetta. Se hyväksyy ääntä muutaman sekunnin verran reaaliajasta edellä (limits.audioBufferS) ja lakkaa lukemasta, kunnes on saanut kiinni. Takaisin päin vahvistukset jonotetaan menettämättä mitään, kun taas luonnokset yhdistetään, joten hidas lukija näkee vähemmän ja tuoreempia luonnoksia. Asiakas, joka ei pysy mukana edes vahvistusten kanssa, saa error-kehyksen koodilla slow_consumer ja retryable: false, ja yhteys katkaistaan. Jos näet sen, viestinkäsittelijäsi tekee liikaa työtä.
Selaimessa streamMicrophone paketista @vocalstack/sdk/mic pyytää mikrofonia, muuntaa näytteenopeuden 16 kHz:iin ja lähettää palat clock-kehysten kanssa. Niiden avulla palvelin merkitsee aukot, kun selain kuristaa välilehden. Sivu saa istunnon omalta reitiltäsi eikä koskaan näe avainta:
import { RealtimeSession } from '@vocalstack/sdk';
import { streamMicrophone } from '@vocalstack/sdk/mic';
export async function transcribeMicrophone(
stream: { sessionId: string; streamUrl: string },
render: (committed: string, tail: string) => void,
): Promise<void> {
const session = await RealtimeSession.connect(stream);
const mic = await streamMicrophone(session); // asks for the microphone, sends 16 kHz PCM
document.querySelector('#stop')?.addEventListener('click', () => {
void mic.stop();
session.eof();
});
let committed = '';
for await (const ev of session) {
if (ev.type === 'transcript.draft') render(committed, ev.segments.map((s) => s.text).join(' '));
if (ev.type === 'transcript.committed') {
committed += `${ev.segment.text} `;
render(committed, '');
}
}
}
render(committed, tail)-allekirjoitus on koko käyttöliittymämalli; seuraava osio kertoo, miksi.
Kaksi kehystyyppiä rakentaa litteraatin, ja niiden sekoittaminen on yleisin virhe.
Ensin siitä, miksi niitä on kaksi. Puhemalli päättää, mikä sana oli, osittain sen perusteella, mitä sanoja seuraa. ”I scream” ja ”ice cream” kuulostavat samalta, kunnes lause jatkuu. Siksi reaaliaikainen tunnistin antaa aluksi arvauksen ja tarkentaa sitä, kun lisää ääntä saapuu, ja vakiintuu vasta, kun on kuullut tarpeeksi. Jokainen suoratoistava puhe-API tuo tämän esiin jollain tavalla. Jotkin kutsuvat arvauksia ”väliaikaisiksi” tai ”osittaisiksi” tuloksiksi ja merkitsevät vakiintuneet is_final-lipulla; sanasto vaihtelee, periaate ei. Tässä arvaus on luonnos ja vakiintunut teksti on vahvistus.
transcript.draft on elävä häntä: sanat, jotka on kuultu viimeisen vahvistuksen jälkeen ja jotka vielä muuttuvat. Jokainen luonnos korvaa edellisen kokonaan. Piirrä häntä uudelleen sen segments-kentän mukaan; älä koskaan liitä sitä perään. Luonnoksissa ei ole seq-kenttää tarkoituksella, joten älä yritä yhdistää niitä vahvistuksiin, joiksi ne muuttuvat.
transcript.committed on lopullinen: yksi segment, jossa on seq (0, 1, 2, ilman aukkoja), start, end, text, speaker ja words sanakohtaisine ajoituksineen. Lisää se perään ja poista luonnos, jonka se kattaa; seuraava luonnos alkaa siitä, mihin tämä vahvistus päättyi.
committed: [seq 0] [seq 1]
draft: "how are" ← frame 1
draft: "how are you do" ← frame 2 REPLACES frame 1
committed: [seq 0] [seq 1] [seq 2 "How are you doing?"]
← the commit consumes the tail; drop the draft
Luonnosten lisääminen näyttää kaksinkertaisen, välkkyvän tekstin. Tekstitysnäkymäsi alkaa nykiä tavalla, jota puhuja ei aiheuttanut. Piirrä kaikki vahvistetut segmentit, sitten viimeisin luonnos niiden jälkeen, ja tyhjennä luonnos, kun vahvistus tulee. Vahvistus voi sisältää sanoja, joita ei ollut missään luonnoksessa.
Tässä muutaman sekunnin puhe langalla, edellä olevan fetch-esimerkin mukaan (translate: ["es"], lauseiden kokoaminen pois päältä):
{"type":"session.loading"}
{"type":"session.ready"}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good","speaker":0,"tentative":true}]}
{"type":"transcript.draft","segments":[{"start":0.0,"text":"Good morning every","speaker":0,"tentative":true}]}
{"type":"transcript.committed","segment":{"seq":0,"start":0.0,"end":1.4,"text":"Good morning, everyone.","speaker":0,"words":[{"start":0.0,"end":0.3,"text":"Good"},{"start":0.3,"end":0.8,"text":"morning,"},{"start":0.8,"end":1.4,"text":"everyone."}]}}
{"type":"transcript.translated","seq":0,"translations":{"es":"Buenos días a todos."}}
{"type":"transcript.draft","segments":[{"start":1.6,"text":"Thanks for","speaker":0,"tentative":true}]}
Kaksi kehystä lisää ympäröi näitä. transcript.amended korjaa vahvistettua segmenttiä paikallaan (sama seq, uusi text tai speaker), kun sen sana on korjattu; sovella se riviin, jonka olet jo piirtänyt. transcript.words striimaa luonnoksen sanaajoitukset juuri ennen vahvistusta. Se vaatii events.words-asetuksen, joka on oletuksena päällä; vahvistus sisältää aina omat words-kenttänsä tapahtumavalinnoista riippumatta. Lopuksi: jätä huomiotta tuntemattomat kehystyypit, sillä uusia lisätään ilmoittamatta.
Tässä Python-SDK:n lukusilmukka, jossa päätteen ikkuna toimii tekstitysnäkymänä. Lauseiden kokoaminen on tässäkin pois päältä, joten käännökset tulevat transcript.translated-kehyksinä, ja error-kehys nousee silmukasta VocalStackError-virheenä:
import asyncio
import os
from vocalstack import RealtimeSession, VocalStack, VocalStackError
MIC = ["ffmpeg", "-loglevel", "error", "-f", "avfoundation", "-i", ":0",
"-f", "f32le", "-ac", "1", "-ar", "16000", "-"]
async def main() -> None:
async with VocalStack("{{api}}", api_key=os.environ["VOCALSTACK_API_KEY"]) as vs:
created = await vs.realtime.create_session(
spoken_language="en", translate=["es"], sentences={"enabled": False}
)
session = await RealtimeSession.connect(created.session_id, created.stream_url)
mic = await asyncio.create_subprocess_exec(*MIC, stdout=asyncio.subprocess.PIPE)
async def feed() -> None:
assert mic.stdout is not None
while chunk := await mic.stdout.read(8000): # 125 ms of audio
await session.send_audio(chunk)
await session.eof() # the server flushes, then sends session.done
feeder = asyncio.create_task(feed())
try:
async with session:
async for event in session:
if event.type == "transcript.draft":
# The live tail: replace it, never append it.
tail = " ".join(s.text for s in event.segments)
print(f"\r\x1b[K\x1b[2m{tail}\x1b[0m", end="", flush=True)
elif event.type == "transcript.committed":
# Final: append it (seq 0, 1, 2...), and the tail it covers is gone.
print(f"\r\x1b[K{event.segment.text}", flush=True)
elif event.type == "transcript.translated":
print(f" es: {event.translations.get('es')}")
await feeder
print(f"done ({session.done_reason})", session.stats)
except VocalStackError as e:
# An `error` frame mid-stream, or a refused connect.
print(f"\nerror {e.code}: {e.message} (retryable: {e.retryable})")
finally:
if mic.returncode is None:
mic.kill()
asyncio.run(main())
Segmenttikohtainen käännös, kuten yllä, on yksinkertainen muoto. Lukijoiden suosima muoto saa oman osionsa.
translate: ["es", "fr"] pyytää käännöksiä istunnon edetessä. Se, miten ne saapuvat, riippuu lauseiden kokoamisesta (sentences: { enabled: true }), joka on oletuksena päällä.
Lauseiden ollessa pois päältä jokaisen vahvistuksen perässä tulee hetken kuluttua transcript.translated-kehys, joka nimeää vahvistuksen seq-arvon ja kantaa translations-kenttää, joka on avattu kielen mukaan. Liitä ne kyseiseen segmenttiin. Näin esimerkit ovat toimineet tähän asti. Se on yksinkertainen ja muistuttaa vahvistuslokia: yksi segmentti, yksi käännös, lauserajat siellä, missä puhuja hengitti.
Lauseiden ollessa päällä palvelin kokoaa vahvistukset kokonaisiksi lauseiksi ja kääntää ne, mikä lukee huomattavasti paremmin. Jokainen transcript.sentence-kehys korvaa yhden lauseen kokonaan, ja avaimena on sentenceId. Sen status etenee tilasta open tilaan closed (tai dropped, joka tarkoittaa hylkäämistä). Sen translations ovat väliaikaisia, kun lause on auki, ja lopullisia, kun se sulkeutuu. Päivitä sentenceId:n mukaan, älä koskaan liitä perään. TypeScript-SDK:ssa on tähän tarkoitettu applySentence:
import { applySentence, type SentenceSegment } from '@vocalstack/sdk';
let sentences: SentenceSegment[] = [];
for await (const ev of session) {
if (ev.type !== 'transcript.sentence') continue;
sentences = applySentence(sentences, ev.sentence); // upsert by sentenceId
if (ev.sentence.status === 'closed') console.log(`${ev.sentence.text}\n es: ${ev.sentence.translations?.es}`);
}
Käännökset seuraavat litteraattia sekunnin tai kahden viiveellä. Jos haluat niiden myös puhuvan, lisää voice: ["es"] (se vaatii lauseet). Kunkin suljetun lauseen espanja saapuu sitten transcript.voice-kehyksenä, joka kantaa klipin base64-koodattuna Ogg/Opus-äänenä 24 kHz monona. Pitkä lause tulee paloina (part / parts), jotka toistetaan peräkkäin. Väliaikaista tekstiä ei koskaan puhuta. Live-käännösopas kattaa äänet tarkemmin, ja missä sekunnit kuluvat selittää viiveketjun.
Kohdekieliä ei lukita luomishetkellä.
Tekstikehys session.update muuttaa spokenLanguage, translate, speakerLabels, latencyMode, voice tai voiceByLang -asetuksia siitä eteenpäin; kentät, jotka jätät pois, pysyvät ennallaan.
{"type":"session.update","translate":["es","fr"],"latencyMode":"fast"}
SDK:t tarjoavat tämän nimellä session.update(…). translate korvaa koko listan, ja [] kytkee käännöksen pois. Jo toimitettuja segmenttejä ei transkriboida tai käännetä uudelleen, ja jos kytket puhujatunnisteet päälle, puhujat merkitään vain siitä hetkestä eteenpäin. Se, mitä on sanottu, pysyy sanottuna; asetukset ohjaavat vain sitä, mitä tulee seuraavaksi.
Kun ääni loppuu, lähetä {"type":"eof"} (SDK:issa session.eof()). Palvelin litteroi sen, mitä se vielä pitää hallussaan, lähettää viimeiset vahvistukset, sitten session.done ja sulkee yhteyden. cancel lopettaa heti ja pudottaa loput.
{"type":"session.done","reason":"eof","stats":{"audioS":30.0,"processingS":30.6,"realtimeFactor":0.98}}
reason on eof, max_duration (istunto saavutti suunnitelmansa enimmäiskeston ja suoritti saman lopetuksen) tai cancelled. stats on palvelimen mittaama: audioS on lähettämäsi ääni, processingS on istunnon seinäkellon aika ja realtimeFactor niiden suhde. Live-istunto päätyy juuri alle yhteen, koska seinäkellon aika sisältää lopullisen tyhjennyksen eof-viestisi jälkeen. Selvästi sitä alhaisempi luku tarkoittaa, että istunto kesti huomattavasti kauemmin kuin ääni kesti; ensimmäinen paikka katsoa on se, kuinka nopeasti ääni saapui. Tiedoston kohdalla sama luku kertoo, kuinka moninkertaisesti nopeammin litteraatti palautui kuin toisto kestää.
Istunto kestää enintään limits.maxDurationS-ajan, joka saadaan kohdasta GET /v1/realtime/capabilities. Sen ääni kuluttaa saldoasi sekunneittain suunnitelmasi live-hinnan mukaan, samalla 5 minuutin vähimmäiskäytöllä kuin missä tahansa litteroinnissa: jokainen istunto, joka vastaanottaa ääntä, laskutetaan vähintään 5 minuuttia (hinnoittelu). Sama yhteys ottaa vastaan myös koko tiedoston. Luo istunto input: { kind: "file" }-asetuksella, lähetä tiedoston omat tavut (mp3, wav, flac, ogg, m4a) ja eof, niin litteraatti palaa moninkertaisesti nopeammin kuin ääni toistuu, ja matkalla tulee transcript.progress-kehyksiä.
Jatkamista ei ole. Katkennut yhteys päättää istunnon; uudelleenyhdistäminen tarkoittaa uuden istunnon luomista, jolle laskutetaan oma vähimmäiskäyttönsä, ja uudelleen striimaamista live-lähteestäsi, seq alkaen taas nollasta. Uudelleenyhdistäminen itsessään on mikä tahansa WebSocket-rajapinnan vakiokuvio, ja sen kannattaa oppia kerran kunnolla:
retryable: true error-kehyksessä, 503 jonka Retry-After on, tai 30 sekuntia ilman kehystä, joka tarkoittaa capacity_exceeded-tilaa. invalid_token ja session_expired tarkoittavat uutta streamUrl-osoitetta palvelimeltasi, eivät uutta yritystä vanhalla.Pidä istunnon luova reitti halpana kutsua ja käsittele sulkeutumista aina uutena alkuna. Ettei jatkamista ole, on hinta siitä, ettei mitään tallenneta; jos litteraatin on selvittävä yhteydestä, tallenna se.
Kun haluat saman striimin tallennettuna nauhoitteena, jälkikäteen korjattuna, vietynä ja luettuna yleisöltä heidän omilla kielillään, julkaise se sen sijaan suorana litterointina. Live-tilaan siirtymisen opas käy saman PCM-yhteyden läpi litteroinnin ja lähetyslinkin kautta, ja kuuntelijan opas näyttää tilaajan puolen: jokainen kuuntelija valitsee kielen, ja myöhään liittyvä saa kiinni lastSeq-osoittimella. Tilanne huoneessa on aiheena artikkelissa Yksi linkki, jokainen kieli.
Tiedostolle, jonka haluat tallentaa, tallennettu reitti on litteroi tiedosto VocalStack API:lla. Täydellinen kehysviite, jokaisen kentän kanssa, on API-viitteen Striimaa mikrofoni -opas.