Paid human listeners judge your audio and give you a number back. Use it when automated metrics (WER, speaker-sim, predicted MOS) can't answer the question.
Two templates, chosen with "template" on create:
template |
A listener hears | and answers | you get |
|---|---|---|---|
tts_preference.v1 (default) |
two clips | which one they prefer (forced choice, no tie) | preference for side B, 0.5 = no preference |
tts_detect.v1 |
one clip | human or AI, plus confidence 1–4 | share of clips taken for human, vs a real-human baseline |
Use preference to pick between two systems or checkpoints. Use detection to find out whether output passes as real.
What Woodstitch does not do: MOS, CMOS, MUSHRA or any 1–5 rating scale. These two
tasks are the whole product. If you need a mean opinion score, this is not the right tool;
if you need to know which of two outputs people prefer, or whether output passes as human,
it is. API studies are forced-choice; the ties fields in results are kept for schema
stability and are 0 on API studies.
GET /v1/templates returns the same thing as JSON, no key needed.
Base URL: https://api.woodstitch.com/v1
Auth: Authorization: Bearer ws_live_...
Machine-readable spec: /openapi.json (OpenAPI 3.1). This page as markdown: /docs.md.
MCP server for Claude Code, Cursor and other agents: claude mcp add woodstitch -e WOODSTITCH_API_KEY=ws_live_... -- uvx woodstitch-mcp (woodstitch-mcp on PyPI).
Always show the quote to the user before calling /start. Step 1 is free and
tells you the exact cost. Step 2 spends real money and cannot be undone once
raters begin answering.
Pass max_total_usd on every start. It is a hard ceiling; the call fails
rather than overspending.
Send Idempotency-Key on every POST. If a call times out, retry with the
same key — you get the original response back instead of a second charge.
Poll as often as you like. GET /v1/evals/{id} is cheap and returns live
numbers. The first judgements usually land within minutes of starting, and the
study completes when the last one does, which depends on its size and how many
listeners are online. poll_after_s is a suggestion, not a limit.
Duplicate protection is automatic. Job identity is a hash of the audio
content plus the question and rating count. Re-running the same script with
different local paths returns the existing evaluation ("duplicate": true)
rather than buying it twice.
Partial scores are published live. GET /v1/evals/{id} returns the current
win rate, CI, and counts from whatever has been rated so far, flagged
"provisional": true with the n it came from. Poll it as often as you like.
Know what you are looking at: deciding to stop because the number crossed a threshold you were watching is optional stopping, and it inflates false positives badly — a test built for p<0.05 behaves more like p<0.2 under continuous peeking. Treat an interim figure as a preview. Whether to act on one is your call.
POST /v1/start (no auth) creates an account in the background, puts the first-study credit on it, and
returns an api_key to use on every later call. Send source (your git remote) so the account has a name;
email is optional. Nothing is asked of the person. One account per call, rate-limited per address.
curl -sX POST https://api.woodstitch.com/v1/start -H "Content-Type: application/json" \
-d '{"source": "github.com/your-org/your-repo"}'
# -> {"api_key":"ws_live_…","credit_usd":25,"next":"POST /v1/files, then POST /v1/evals …"}
To open the account in a browser later, attach an email with POST /v1/account/email {"email": "…"} and sign
in at https://woodstitch.com/app (a sign-in link is emailed). The step-by-step recipe an agent should follow is
llms.txt.
# 0. Upload your audio. Skip this if it already sits at public URLs. Any number of files per call.
curl -sX POST https://api.woodstitch.com/v1/files \
-H "Authorization: Bearer $WOODSTITCH_KEY" \
-F "file[]=@base/001.wav" -F "file[]=@cand/001.wav" -F "file[]=@base/002.wav" -F "file[]=@cand/002.wav"
# -> {"files":[{"id":"file_7c1e…","name":"001.wav","duration_s":12.4,"format":"wav"}, ...]}
# 1. Create — measures every clip, returns a price. CHARGES NOTHING.
curl -sX POST https://api.woodstitch.com/v1/evals \
-H "Authorization: Bearer $WOODSTITCH_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ckpt-42-create" \
-d '{
"name": "ckpt-42 vs baseline",
"question": "Which audio sample do you prefer if you were listening to an audiobook?",
"ratings_per_pair": 5,
"pairs": [
{"id":"utt-001","a":"file_7c1e…","b":"file_09aa…"},
{"id":"utt-002","a":"file_b3d0…","b":"file_55f2…"}
]
}'
# -> {"eval_id":7,"quote":{"id":3,"total_usd":5.80},"nothing_charged_yet":true,
# "dashboard_url":"https://woodstitch.com/app/evaluations/7",...}
# 2. Start — THIS SPENDS MONEY. Confirm the amount with a human first.
curl -sX POST https://api.woodstitch.com/v1/evals/7/start \
-H "Authorization: Bearer $WOODSTITCH_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ckpt-42-start" \
-d '{"quote_id":3,"max_total_usd":10.00}'
# 3. Poll as often as you like: live numbers, first judgements usually within minutes.
curl -s https://api.woodstitch.com/v1/evals/7 -H "Authorization: Bearer $WOODSTITCH_KEY"
# 4. Results
curl -s https://api.woodstitch.com/v1/evals/7/results -H "Authorization: Bearer $WOODSTITCH_KEY"
Free to check, no key needed:
curl -s https://api.woodstitch.com/v1/pricing
Priced per judgement, by how many seconds of audio the rater hears — measured server-side from your audio, never from a number you declare, and never rounded up to a band.
usd_per_judgement = audio_seconds / 3600 × 18.00 (rounded up to the cent)
| Template | audio_seconds is |
15 s clips | 30 s clips | 60 s clips |
|---|---|---|---|---|
tts_preference.v1 |
dur_a + dur_b — the rater hears both clips |
$0.15 | $0.30 | $0.60 |
tts_detect.v1 |
the clip's own duration — one clip | $0.08 | $0.15 | $0.30 |
A 10 s + 50 s pair costs exactly what a 30 s + 30 s pair costs. Detection costs half of preference at the same clip length because the rater hears one clip instead of two.
Every pair or clip is priced independently. One long outlier does not
reprice the rest, and dry_run returns each unit's measured listening time
and its price so you can see exactly what you are buying before paying.
preference: total = sum over pairs of (usd_per_judgement × ratings_per_pair)
detection: total = sum over clips of (usd_per_judgement × ratings_per_clip)
200 pairs × 30 s clips × 5 ratings = 1,000 judgements = $300.00 600 clips × 30 s × 8 ratings = 4,800 judgements = $720.00
Clips must be 0.5–120 s. Up to 5,000 pairs or clips per evaluation.
You hold a balance and it drains as judgements arrive — there is no up-front reservation of the whole study.
/start needs a positive balance, not the whole quote. Run out of funds and
the eval pauses (pause_reason: insufficient_funds) rather than failing.
Top up and it resumes by itself.POST /v1/evals/{id}/cancel stops it. You are charged only for work already
handed out; max_additional_charge_if_canceled_now_usd on the progress
response tells you that figure before you decide.How listeners are recruited, paid and screened is in the platform methodology. Rate quality is the product.
This is the question most people get wrong. Preference gaps are small, and required sample size explodes as the gap narrows:
| True gap (Elo) | Expected preference | Judgements for 80% power |
|---|---|---|
| 50 | 57% | ~400 |
| 30 | 54% | ~1,100 |
| 20 | 53% | ~2,400 |
| 10 | 51.4% | ~9,500 |
| 2 | 50.3% | ~237,000 |
Those are idealized. Ties and repeated raters push the real number higher. If you are trying to resolve a sub-10-Elo difference, this is the wrong tool — no human panel will settle it affordably.
A good default for "is my new checkpoint better than baseline?" is 200 pairs × 5 ratings, which reliably detects moderate-to-large differences.
POST /v1/files — upload audioMultipart. One file as -F file=@x.wav, or several as repeated file[] parts (up
to 100 files or 1 GB per call, 50 MB per file; a plain repeated file= keeps
only the last one, and the API tells you so). Each file is decoded, measured and
stored; you get back an id to use anywhere create takes a URL.
curl -sX POST https://api.woodstitch.com/v1/files \
-H "Authorization: Bearer $WOODSTITCH_KEY" \
-F "file[]=@base/001.wav" -F "file[]=@cand/001.wav"
{"object":"list","count":2,"files":[
{"id":"file_7c1e4f…","object":"file","name":"001.wav","duration_s":12.4,"format":"wav","bytes":1190456,"created_at":"2026-11-05T19:02:11Z"},
{"id":"file_09aa31…","object":"file","name":"001.wav","duration_s":12.1,"format":"wav","bytes":1162004,"created_at":"2026-11-05T19:02:11Z"}
]}
A single-file call returns that file object directly (with the same files
list attached). Files are returned in the order you sent them and carry their
original name, so pairing two folders by filename is a one-liner. Ids are
private to your account. If one file in a call is bad, the call fails and
nothing from it is recorded; fix the file and resend the call. Re-sending the
same bytes is harmless: storage is content-addressed, so nothing is duplicated.
Also: GET /v1/files (your uploads, newest first), GET /v1/files/{id}, and
DELETE /v1/files/{id} to remove an upload from your account. Studies that
already used a file keep their copy.
POST /v1/evals — create and priceFree. Resolves every clip (a file id or a URL it downloads), validates it decodes, measures duration, stores an immutable copy, and returns a quote.
| Field | Required | Default | Notes |
|---|---|---|---|
template |
no | tts_preference.v1 |
Or tts_detect.v1. |
pairs |
preference | — | Array of {id, a, b}. Max 5000. a/b are file ids from POST /v1/files or public http(s) URLs. |
clips |
detection | — | Array of {id, audio, truth, source, speaker, passage, duration_bucket}. Max 5000. audio is a file id or a URL. |
ratings_per_pair |
no | 5 | 1–50. Independent judgements per pair. (judgments_per_item is accepted as an alias.) |
ratings_per_clip |
no | 5 | Detection's name for the same thing. |
pairs[].system_a, pairs[].system_b |
no | — | Labels for the two sides (model ids). Never shown to raters. Needed for per_participant rules on system. |
pairs[].passage, pairs[].speaker |
no | — | Text id and voice id, for no-repeat rules and your own analysis. |
per_participant |
no | — | No-repeat rules for one listener; see Controlling who hears what. Works on both templates. |
allocation |
detection only | — | Study-wide balancing. Refused on preference studies, where every pair gets ratings_per_pair. |
question |
no | per template | Max 200 chars. Shown verbatim to raters. |
name |
no | "eval" | Your label. |
dry_run |
no | false | Price it and throw it away. Nothing is stored, no eval_id. |
See the detection template for what
each clip field means and why speaker and passage are required.
Pricing without committing: send "dry_run": true to get the quote and
pair_seconds — every pair's measured listening time and its price per
judgement — with nothing persisted. Use it to spot an over-long pair before
you commit.
The create response also carries a warnings array: it flags an underpowered
design, and flags any pair whose two sides are in different audio formats
(a WAV-vs-MP3 pair risks measuring your encoder instead of your model).
Audio: wav, mp3, flac, ogg, m4a. Each clip 0.5–120s, max 50MB. Give each clip as
a file id from POST /v1/files (uploaded from disk) or as a public http(s) URL.
A URL must stay reachable for the duration of this call only — we keep our own
copy. Redirects are not followed, so use direct or presigned links.
Which side is which: a and b are preserved exactly as you send them.
Raters see them in randomized order and are never told what either one is.
Use the same format and bitrate on both sides. If a is 48kHz WAV and b
is 128kbps MP3, you are partly measuring your encoder, not your model. Export
both sides identically.
pairs[].id must be unique within the evaluation, max 64 characters. It is
echoed back in per_pair so you can join results to your own data.
Comparing several systems at once. Send one pair per matchup, label the
sides with system_a / system_b, give each text a passage id, and add a
per_participant block so no listener hears one system often enough to
recognise it:
"pairs": [
{"id":"p01-x-y","a":"https://.../x_p01.wav","b":"https://.../y_p01.wav",
"system_a":"x","system_b":"y","passage":"p01"}
],
"per_participant": {"max_judgements": 16, "unique_by": ["passage"], "max_per": {"system": 7}}
The result is still per pair; the export carries the labels so you can tally wins per system yourself.
POST /v1/evals/{id}/start — begin (spends money)| Field | Required | Notes |
|---|---|---|
quote_id |
no | If given, must match the current quote, else quote_mismatch. |
max_total_usd |
no | Hard ceiling. Fails with over_max rather than overspending. |
Needs a positive balance and a key whose spend cap can cover the quote. Nothing is debited here; billing is metered from the first judgement.
GET /v1/evals/{id} — progress and live scoreReturns the submitted question, pairs, ratings_per_pair, cost_usd and
started_at, plus judgements.done / .target / .percent,
pairs_with_data, and the same metrics / counts shape as /results —
computed from whatever has been rated so far.
"provisional": true until the evaluation completes. Status values: ready,
running, completed, canceled, failed.
Everything an evaluation needs to be resumed is in this response, so an agent
that restarts only needs the eval_id.
Every response that names an evaluation also carries dashboard_url, the
page where a person can watch the same study in the browser
(https://woodstitch.com/app/evaluations/{id}, sign-in required). Hand it to
the user when you report the eval_id.
GET /v1/evals/{id}/results — final numbers409 not_ready while the evaluation is still running. Available once it is
completed, or canceled — a canceled study returns whatever was judged
before the stop, with "incomplete": true and judgements_target so you can
see how far it got.
{
"metrics": {
"human_pref/b_win_rate": 0.7222,
"human_pref/b_win_rate_ci95_low": 0.6667,
"human_pref/b_win_rate_ci95_high": 0.8333,
"human_pref/tie_rate": 0.1111,
"human_pref/pairs": 3,
"human_pref/judgements": 9
},
"counts": {"a_wins": 2, "b_wins": 6, "ties": 1},
"per_pair": [{"id":"utt-001","a_wins":1,"b_wins":2,"ties":0}]
}
metrics is a flat numeric dict, so it drops straight into a tracker:
wandb.log(result["metrics"])
Reading the score. b_win_rate is (b_wins + 0.5*ties) / total — the share
of preference for your b side. 0.5 means indistinguishable. Ties count
half to each side, because a pair nobody can tell apart is evidence of parity,
not evidence for either one.
The confidence interval is bootstrapped by pair, not by vote. Multiple judgements on the same pair are not independent, so a naive binomial interval would be far too narrow. If the interval spans 0.5, you have not shown a difference.
GET /v1/evals/{id}/export — per-judgement rowsOne row per judgement, with every clustering dimension: rater (pseudonymous,
stable within the eval), source, speaker, passage, duration bucket, answer,
confidence, time spent. Add ?format=csv for CSV.
This is what you fit your own mixed-effects model on.
POST /v1/evals/{id}/cancel — stop earlyCharges you only for work already handed to raters. Anyone mid-task finishes
and is paid. Check max_additional_charge_if_canceled_now_usd on the progress
response first if you want to know that number before deciding.
POST /v1/start — account + key + first-study credit, no authSee Start without one. Returns api_key, credit_usd, account.
POST /v1/account/email — attach an email to the account{"email": "…"}. Needed once before the dashboard sign-in link can find an account created by /start without an email.
POST /v1/account/topup — add fundsA paused evaluation resumes automatically when the money lands.
GET /v1/account — balance and spendGET /v1/evals — your last 50 evaluationsGET /v1/files — your uploads · GET /v1/files/{id} · DELETE /v1/files/{id}GET /v1/templates — both templates, their inputs and metrics (no auth)GET /v1/pricing — rate card for both templates (no auth)tts_detect.v1A listener hears one clip and answers "was this made by a human or AI?", then rates their confidence 1–4. Unlike preference, this template has a right answer, so you send ground truth with every clip.
| Field | Required | Notes |
|---|---|---|
id |
yes | Unique within the eval. Echoed back in results. |
audio |
yes | Public http(s) URL. Same formats and limits as preference. |
truth |
yes | "human" or "ai". Never sent to a listener. |
source |
yes* | The model id ("eleven_v4"). Real narration uses the reserved value "human" — and may omit the field. |
speaker |
yes | Voice id for AI, narrator id for human. |
passage |
yes | The text id. Use the same id for every rendering of the same text, across every source. |
duration_bucket |
no | Your design condition, e.g. "15s", "30s", "60s". Free text, max 16 chars. |
speaker and passage are required because the result resamples over them to
work out its uncertainty. Without them there is no honest interval to report.
curl -sX POST https://api.woodstitch.com/v1/evals \
-H "Authorization: Bearer $WOODSTITCH_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: realism-001" \
-d '{
"template": "tts_detect.v1",
"name": "realism vs human narration",
"ratings_per_clip": 8,
"clips": [
{"id":"h-p01","audio":"https://.../human_p01.wav",
"truth":"human","source":"human","speaker":"narrator_03","passage":"p01","duration_bucket":"30s"},
{"id":"e-p01","audio":"https://.../eleven_p01.wav",
"truth":"ai","source":"eleven_v4","speaker":"rachel","passage":"p01","duration_bucket":"30s"}
]
}'
A fooling rate on its own means nothing: if 40% of your clips are called human, that could be a realistic model or an inattentive panel, and you cannot tell which. The real-human baseline is what separates the two, so include genuine narration of the same passages and let the API compute the gap.
Create returns a warning if you leave it out, and no baseline_deficit.
{
"metric": "human_label_rate",
"results": [
{"source":"eleven_v4","duration":"30s","human_label_rate":0.41,"n":480,
"ci95":{"tested_stimuli":{"low":0.37,"high":0.46},
"model": {"low":0.28,"high":0.55}},
"baseline_deficit":0.37,
"baseline_deficit_ci95":{"tested_stimuli":{"low":0.31,"high":0.43},
"model": {"low":0.19,"high":0.54}}}
]
}
human_label_rate is the share of judgements that called the recording human.
For an AI source that is the fooling rate; for source: "human" it is the
recognition rate. baseline_deficit is the human rate minus this source's
rate — 0 means indistinguishable from real narration on these stimuli.
Every number comes with two confidence intervals:
scope |
Answers | Resamples |
|---|---|---|
tested_stimuli |
"What if a different panel heard these exact clips?" | raters |
model |
"What if I re-ran this with different voices and passages from the same model?" | raters, voices and passages together |
The model interval is typically 3–4× wider. That is not pessimism, it is the
cost of a claim about a model rather than about a set of files.
This was measured, not assumed. Simulated against a known ground truth on a 4-source / 5-voice / 30-passage / 400-rater design, at nominal 95%:
| interval | covers its own estimand | covers the model-level truth |
|---|---|---|
tested_stimuli |
96.7% | 46.7% |
model |
— | 93.8–96.0% |
Quoting the narrow interval as a statement about a model is wrong more than
half the time. If you are going to publish a sentence of the form "model X
fools listeners Y% of the time", the model interval is the one that belongs
next to it. Use tested_stimuli when the claim really is about the specific
files you tested — a release gate on this build, for instance.
The response also carries a generalisation block stating in machine-readable
form what the data does and does not support, and coverage with the counts
behind it.
If a source has few voices, buying more judgements will not fix the model
interval — between-voice variance is what makes it wide, and only more voices
reduce it. With fewer than 8 voices the response says
"to_the_model": "not_established" and tells you so in warnings.
The honest way to report a 5-voice study is "these five voices", not "this model".
By default every clip gets ratings_per_clip judgements and any rater may be
served any clip. Two optional blocks change that. per_participant works on
both templates (for preference, the system field reads the pair's
system_a / system_b labels); allocation is detection only. They are named for their
scope, which is the thing to get right: a rule either applies across the
whole study or within one participant.
{
"template": "tts_detect.v1",
"clips": [ ... ],
"allocation": {
"total_judgements": 2400,
"balance_by": ["truth", "duration_bucket"]
},
"per_participant": {
"max_judgements": 12,
"counts": { "truth": { "human": 6, "ai": 6 } },
"unique_by": ["speaker", "passage"]
}
}
allocation — study-wide targets.
| Field | Means |
|---|---|
total_judgements |
How many judgements to buy in total. |
balance_by |
Target equal completed judgements in each combination of these fields. |
balance_by is what lets a small group of clips carry as much weight as a
large one. With 36 human clips and 216 AI clips, balance_by: ["truth"] gives
each human clip ~33 judgements and each AI clip ~6 — so the human baseline is
estimated as precisely as the models, which a single uniform count cannot do.
Balance on every field whose comparison you care about. A field you leave
out will drift, because the remainder from integer division lands wherever it
happens to land. If the study compares durations, name duration_bucket.
per_participant — one person's experience.
| Field | Means |
|---|---|
max_judgements |
Most clips this rater will ever hear in this study. Not per sitting — per person. |
counts |
Required mix within that, e.g. 6 human and 6 AI. Must sum to max_judgements. |
unique_by |
Fields that may not repeat for a rater. |
unique_by is the one that protects a repeated-measures design. If a rater
hears voice V at 60 seconds and again at 15 seconds, they recognise the voice
and carry over their earlier answer — and because longer clips are more
recognisable, that contamination flows from the long arm into the short one
and flattens the very effect being measured. unique_by: ["speaker",
"passage"] prevents it. Valid fields: speaker, passage, source,
truth, duration_bucket, system.
max_per caps how often a rater may meet the same value of a field, where
unique_by is the special case of a cap of 1. It exists because an A/B field
like system is too scarce to be unique — five systems make only two disjoint
pairs — so "max_per": {"system": 7} lets a rater work through the study while
stopping anyone hearing one system often enough to start recognising it.
These two are independent: no repeated speaker and no repeated passage, not merely no repeated speaker-passage pair.
Per-participant balance is not inferred from global balance. They are
different designs. If each rater must hear 6 human and 6 AI, say so in
counts; balance_by alone only balances the totals.
The response carries effective_design — the compiled plan, so you can check
what you are buying before paying:
"effective_design": {
"judgements_total": 2400,
"ratings_per_clip": { "min": 6, "max": 34 },
"judgements_by": {
"truth": { "human": 1200, "ai": 1200 },
"duration_bucket": { "15s": 800, "30s": 800, "60s": 800 }
},
"per_participant": { "max_judgements": 12, ... }
}
An impossible design is refused at create time rather than stalling halfway
through a paid study. If unique_by includes passage and a rater needs 12
clips but you supplied 8 passages, that is infeasible_session with the
numbers in the message.
These constraints mean some raters cannot be given a valid session and are simply turned away — a safe outcome, because the alternative is serving a clip that violates the design.
Simulated on a 12-passage, 6-voice, 3-duration study: 91% of target at 200 raters, 96% at 250, and flat after that. More raters past that point mostly get declined. Plan for a shortfall and read the completed counts in the results rather than assuming every target is met.
Raters pick 1 (guessing) to 4 (very confident). It is exploratory only: it never affects rater pay, never filters a judgement, never gates quality — and raters are told that, which is the only reason the numbers mean anything. Results include an accuracy-by-confidence breakdown as a calibration check.
A listener who cannot judge (audio failed) records an abstention, not confidence 1. Abstentions are reported separately, the rater is still paid, and you are not charged — the clip goes back out to someone else.
GET /v1/evals/{id}/export returns one row per judgement with every clustering
dimension (rater, speaker, passage, duration, answer, confidence, timing).
Add ?format=csv for CSV. Rater ids are pseudonyms, stable inside the eval.
If you want the hierarchical logistic model this API does not fit, that export is what you fit it on.
Create runs these and returns anything it finds in warnings:
Run with "dry_run": true first to see all of it for free.
Always:
{"error": {"code": "insufficient_funds", "message": "Balance is $0.00. Add funds to begin.", "hint": "Top up at https://woodstitch.com/app"}}
| Code | HTTP | Meaning |
|---|---|---|
no_key / bad_key |
401 | Missing or invalid Authorization: Bearer. |
insufficient_funds |
402 | Balance is empty. Billing is metered, so /start needs a positive balance, not the whole quote; a running study pauses instead if it runs dry. |
spend_cap |
402 | This key's cap would be exceeded. |
over_max |
400 | Quote exceeds your max_total_usd. |
idempotency_conflict |
409 | Key reused with a different body. |
not_ready |
409 | Results requested while still running. |
bad_allocation |
400 | allocation sent on a preference study, or malformed. |
bad_audio |
400 | Clip won't decode, or is outside 0.5–120s. |
fetch_failed |
400 | We couldn't download the URL. |
bad_file |
400 | A file id that is not on this account, or whose audio is gone. |
no_files / upload_failed / too_many_files |
400 | Nothing usable in a POST /v1/files call; the message names the file. |
upload_too_large |
413 | A call over 1 GB, or a file over 50 MB. Send fewer files per call. |
quote_expired |
400 | Quotes last 24h. Create again. |
Sign in, hear clip A and clip B in full (the choice is locked until both finish playing), and pick A, B, or "sounds the same." They're paid per answer. They are never told what the systems are, and side order is randomized per rater, so position bias can't favour either side.