MobTranslate styleguide

Audio and Playback

Reflect real media state, retain the surrounding task and tie language playback to retrievable, permitted source media.

Media purpose and source boundary

Name the asset, its speaker/source/rights where applicable and the task it supports. This demonstration is a six-second deterministic 440Hz signal with known bytes, not speech, pronunciation, a music recording or a cultural asset. It starts muted and never autoplays.

Real language audio must retain source attribution, content-language metadata, permitted use and review scope. Availability of a recording does not establish pronunciation accuracy or authorization for a different audience.

Anatomy and measured specifications

PartActual native/exported composition
Media elementNamed native audio with controls and metadata preload. It is a real mono 16-bit PCM WAV at 22050Hz; generated duration is six seconds.
CommandsNative 44px-minimum exported Play/Pause/Restart and source/retry controls. They call the actual element, not a simulated timer.
PositionNamed exported Slider with 0.1-second step and native duration bounds. Unknown duration is not zero completion.
Volume / rateLabeled native range/select plus exported mute Checkbox. Native volume/rate events reconcile actual values back into controls.
State / noteNative metadata/time/seek/playing/pause/wait/stall/end/error events, task-specific text and separately retained page-memory note.
ProvenanceDeterministic generator, manifest and SHA-256; the manifest is checked against exact generated bytes.

Working real media player

Play, pause, seek, change rate/volume/mute, trigger a real missing-source failure and retry a fresh media-element lifetime. Position is read from the native currentTime property and reconciled by media events; the retained note is independent from media success.

Project-authored 6-second 440Hz test signal, not speech, pronunciation or a cultural asset. Playback starts only after a command. Muted by default; unmute only if you want to listen.

0.0 of unknown seconds, from native media state.

Actual media state: loading.

Signal SHA-256: 8ed6bc52e1e6b08278f607e6d840367b33ef819d0ac988a59caad976feebce04. Native playback time is decoder/clock evidence; it does not prove physical speaker output, language accuracy or durable notes.

tsx
'use client';
import {useEffect,useRef,useState,type CSSProperties} from 'react';
import {Button,Checkbox,Slider,Textarea,DirectionProvider} from '@mobtranslate/ui';
import manifest from '../public/fixtures/ui-tone.json';
const action:CSSProperties={minHeight:44,height:'auto',minWidth:0,maxWidth:'100%',whiteSpace:'normal',overflowWrap:'anywhere'};
export function AudioPlayerExample(){
  const [missing,setMissing]=useState(false),[retry,setRetry]=useState(0),[muted,setMuted]=useState(true),[volume,setVolume]=useState(.25),[rate,setRate]=useState(1),[time,setTime]=useState(0),[duration,setDuration]=useState(0),[phase,setPhase]=useState('loading'),[error,setError]=useState(''),[note,setNote]=useState('English-only retained playback note.'),[rtl,setRtl]=useState(false);
  const audio=useRef<HTMLAudioElement>(null);
  const key=`${missing?'missing':'signal'}-${retry}`;
  useEffect(()=>{const node=audio.current;if(!node)return;node.volume=volume;node.playbackRate=rate;},[key,volume,rate]);
  useEffect(()=>{const node=audio.current;if(node){if(node.error){setPhase('failed');setError(`Local media unavailable (native code ${node.error.code}). Correct the source and retry; no pronunciation is inferred.`);}else if(Number.isFinite(node.duration)&&node.readyState>=1){setDuration(node.duration);setTime(node.currentTime);setPhase(node.ended?'ended':node.paused?(node.currentTime>0?'paused':'ready'):'playing');}}return()=>{node?.pause();node?.removeAttribute('src');node?.load();};},[key]);
  async function play(){const node=audio.current;if(!node)return;try{await node.play();if(audio.current===node)setError('');}catch(failure){if(audio.current!==node)return;setPhase('failed');setError(`Native playback rejected: ${failure instanceof Error?failure.name:'unknown error'}. The note remains; correct the source and try again.`);}}
  function source(next:boolean){audio.current?.pause();setMissing(next);setRetry(value=>value+1);setTime(0);setDuration(0);setPhase('loading');setError('');}
  function seek(value:number|readonly number[]){const next=typeof value==='number'?value:value[0],node=audio.current;if(node&&Number.isFinite(next)&&Number.isFinite(node.duration)){node.currentTime=Math.min(node.duration,Math.max(0,next));setTime(node.currentTime);}}
  return <DirectionProvider direction={rtl?'rtl':'ltr'}><div data-audio-lab lang="en" dir={rtl?'rtl':'ltr'} className="space-y-4" style={{overflowWrap:'anywhere'}}>
    <p dir="auto">Project-authored {manifest.durationSeconds}-second {manifest.frequencyHz}Hz test signal, not speech, pronunciation or a cultural asset. Playback starts only after a command. Muted by default; unmute only if you want to listen.</p>
    <DirectionProvider direction="ltr"><div dir="ltr" data-media-controls className="space-y-4">
    <audio ref={audio} key={key} id="local-test-audio" controls preload="metadata" muted={muted} src={missing?'/fixtures/ui-tone-missing.wav':manifest.source} aria-label="Generated local test signal" style={{width:'100%',maxWidth:'100%'}}
      onLoadedMetadata={event=>{if(event.currentTarget!==audio.current)return;setDuration(event.currentTarget.duration);setPhase('ready');}}
      onDurationChange={event=>{if(event.currentTarget===audio.current&&Number.isFinite(event.currentTarget.duration))setDuration(event.currentTarget.duration);}}
      onTimeUpdate={event=>{if(event.currentTarget===audio.current)setTime(event.currentTarget.currentTime);}}
      onSeeked={event=>{if(event.currentTarget===audio.current)setTime(event.currentTarget.currentTime);}}
      onPlaying={event=>{if(event.currentTarget===audio.current)setPhase('playing');}}
      onWaiting={event=>{if(event.currentTarget===audio.current)setPhase('buffering');}}
      onStalled={event=>{if(event.currentTarget===audio.current)setPhase('buffering');}}
      onPause={event=>{if(event.currentTarget===audio.current&&!event.currentTarget.ended)setPhase('paused');}}
      onEnded={event=>{if(event.currentTarget===audio.current)setPhase('ended');}}
      onError={event=>{if(event.currentTarget!==audio.current)return;setPhase('failed');setError(`Local media unavailable (native code ${event.currentTarget.error?.code??'unknown'}). Correct the source and retry; no pronunciation is inferred.`);}}
      onVolumeChange={event=>{if(event.currentTarget===audio.current){setVolume(event.currentTarget.volume);setMuted(event.currentTarget.muted);}}}
      onRateChange={event=>{if(event.currentTarget===audio.current)setRate(event.currentTarget.playbackRate);}}
    />
    <div className="flex flex-wrap gap-3"><Button style={action} type="button" onClick={()=>void play()}>Play local test signal</Button><Button style={action} type="button" variant="outline" onClick={()=>audio.current?.pause()}>Pause local test signal</Button><Button style={action} type="button" variant="outline" disabled={!duration} onClick={()=>seek(0)}>Restart local position</Button></div>
    <label id="local-audio-position-label" className="block">Actual playback position</label><Slider aria-labelledby="local-audio-position-label" value={time} min={0} max={duration||manifest.durationSeconds} step={.1} disabled={!duration||phase==='failed'} onValueChange={seek} getAriaValueText={(_,value)=>`${value.toFixed(1)} of ${duration.toFixed(1)} seconds`} />
    <p dir="auto" data-audio-time>{time.toFixed(1)} of {duration?duration.toFixed(1):'unknown'} seconds, from native media state.</p>
    <Checkbox label="Mute local test signal" checked={muted} onCheckedChange={setMuted} />
    <label htmlFor="local-audio-volume" className="block">Local signal volume</label><input id="local-audio-volume" type="range" min={0} max={1} step={.05} value={volume} onChange={event=>setVolume(Number(event.target.value))} style={{width:'100%',minHeight:44}} />
    <label htmlFor="local-audio-rate" className="block">Playback rate</label><select id="local-audio-rate" className="mt-input" value={rate} onChange={event=>setRate(Number(event.target.value))}>{![.75,1,1.25,1.5,2].includes(rate)&&<option value={rate}>{rate}× native rate</option>}{[.75,1,1.25,1.5,2].map(value=><option key={value} value={value}>{value}×</option>)}</select>
    </div></DirectionProvider>
    <div className="flex flex-wrap gap-3"><Button style={action} type="button" variant="outline" aria-pressed={missing} onClick={()=>source(!missing)}>Use missing audio source</Button><Button style={action} type="button" variant="outline" onClick={()=>source(missing)}>Retry current audio source</Button><Button style={action} type="button" variant="outline" aria-pressed={rtl} onClick={()=>setRtl(value=>!value)}>Use RTL audio layout</Button></div>
    <p role="status" dir="auto" data-audio-status>Actual media state: {phase}. {error}</p>
    <label htmlFor="local-audio-note" className="block">Retained local playback note</label><Textarea id="local-audio-note" dir="auto" value={note} onChange={event=>setNote(event.target.value)} />
    <p dir="auto">Signal SHA-256: <code>{manifest.sha256}</code>. Native playback time is decoder/clock evidence; it does not prove physical speaker output, language accuracy or durable notes.</p>
  </div></DirectionProvider>;
}

Exact reproducible audio source

Run audio:check to compare the WAV and manifest against this generator. A verified file hash and a moving playback clock are separate from physical speaker output.

js
import {readFile,writeFile} from 'node:fs/promises';
import {createHash} from 'node:crypto';
const rate=22050,seconds=6,frequency=440,frames=rate*seconds;
const bytes=Buffer.alloc(44+frames*2);
bytes.write('RIFF');bytes.writeUInt32LE(bytes.length-8,4);bytes.write('WAVEfmt ',8);bytes.writeUInt32LE(16,16);bytes.writeUInt16LE(1,20);bytes.writeUInt16LE(1,22);bytes.writeUInt32LE(rate,24);bytes.writeUInt32LE(rate*2,28);bytes.writeUInt16LE(2,32);bytes.writeUInt16LE(16,34);bytes.write('data',36);bytes.writeUInt32LE(frames*2,40);
for(let index=0;index<frames;index++){const t=index/rate;const envelope=Math.min(1,t/.03,(seconds-t)/.03);bytes.writeInt16LE(Math.round(32767*.08*envelope*Math.sin(2*Math.PI*frequency*t)),44+index*2);}
const manifest={title:'Generated local audio test signal',source:'/fixtures/ui-tone.wav',frequencyHz:frequency,durationSeconds:seconds,sampleRateHz:rate,channels:1,bitsPerSample:16,sha256:createHash('sha256').update(bytes).digest('hex'),provenance:'Project-authored deterministic synthetic signal. No speech, pronunciation, music recording or cultural asset.'};
const json=JSON.stringify(manifest,null,2)+'\n';
const audio=new URL('../public/fixtures/ui-tone.wav',import.meta.url),metadata=new URL('../public/fixtures/ui-tone.json',import.meta.url);
if(process.argv.includes('--check')){if(!(await readFile(audio)).equals(bytes)||await readFile(metadata,'utf8')!==json)throw Error('Audio fixture drift; regenerate with scripts/generate-audio-fixture.mjs.');}
else{await writeFile(audio,bytes);await writeFile(metadata,json);}
console.log(`Audio fixture ${process.argv.includes('--check')?'verified':'generated'}: ${bytes.length} bytes, ${manifest.sha256}.`);

Manifest and rights scope

The manifest establishes this generated technical fixture’s identity and sample properties. It contains no speaker identity, cultural permission or language review claim. Do not reuse its signal as if it were a pronunciation sample.

json
{
  "title": "Generated local audio test signal",
  "source": "/fixtures/ui-tone.wav",
  "frequencyHz": 440,
  "durationSeconds": 6,
  "sampleRateHz": 22050,
  "channels": 1,
  "bitsPerSample": 16,
  "sha256": "8ed6bc52e1e6b08278f607e6d840367b33ef819d0ac988a59caad976feebce04",
  "provenance": "Project-authored deterministic synthetic signal. No speech, pronunciation, music recording or cultural asset."
}

Media direction inside an RTL interface

The surrounding layout and task note can be RTL. Playback controls, the seek timeline and volume/rate controls stay in an explicit LTR DOM region with a matching LTR DirectionProvider. Media position is not reading-order navigation: Right advances and Left rewinds in either interface direction. Direction is not inferred from a language name. Native media controls retain browser ownership, and their exact appearance/input support varies.

Playback, pending promises and source replacement

  • After assigning a seek, read currentTime back and update the controlled slider immediately. Reconcile later seeked/timeupdate events from that same element lifetime. Waiting only for an asynchronous event can make a quick reverse key use stale state and lose the intended seek; do not invent time with a timer.
  • Treat play() as an attempt; its promise can reject and native events establish actual playback state. Keep the label/state honest through blocked playback, unsupported media and buffering. See the linked MDN method contract.
  • Reconcile already-loaded native metadata/error state when the element mounts, since preload events may arrive before hydration. Use native currentTime/duration and seek events; do not advance a fake timer or announce ended before the element ends. Volume, mute and rate are actual element properties.
  • Source/retry replacement gets a fresh keyed element. Old events and play-promise outcomes cannot mutate the replacement’s UI; cleanup pauses/releases the old source.
  • A retry changes the local loader lifetime. It is not proof of cache bypass, transport abortion on every platform, authorized source availability or a corrected pronunciation.
  • Failure retains the note and useful recovery controls. Muting affects sound output, not source rights or successful learning.

State matrix and alternatives

StateVisible policy
Metadata unknownUnknown duration and unavailable position editing; keep the real task name.
Ready / playing / pausedReflect actual element state and native position; Play does not prove physical output.
Waiting / stalledShow buffering while retaining current position/task; do not turn it into completion.
EndedActual native ended event; allow restart and preserve notes.
Blocked / failed sourceReport useful rejection/error scope, correction/retry and an alternative path without inventing missing audio.
Media unavailable for languageOffer reviewed source text/transcription or honest abstention according to the source; the synthetic signal supplies no language substitute.

API, keyboard and adaptive layout

  • The native media element owns playback APIs/events. Exported Button/Slider/Checkbox only compose the supporting UI; no AudioPlayer package export or speech-generation service is claimed.
  • Keep custom controls named with units and real targets. Native controls vary across user agents; keyboard/assistive-technology behavior needs consuming-platform checks.
  • Explicit DirectionProvider governs the themed slider’s keyboard/direction contract. Language metadata and the chronological time value remain separate from interface layout direction.
  • Keep controls/note within the viewport while letting long hashes/source labels wrap. Avoid a fixed player covering reading text, virtual keyboards or source attribution.

Acceptance and scope

  • Run test:learning-offline, audio:check, source/token freshness, lint/types/build and the complete route/fragment crawl. Inspect actual changed values, result/state ownership, recovery, keyboard focus and narrow theme/direction geometry.
  • Browser model, storage readback and media-clock evidence do not establish language fluency, qualified review, confidential authorization, physical speaker output or durable backend synchronization. Verify those consuming systems separately.