2026-07-05 00:44:08 +02:00
const App = {
audioContext : null ,
fileInfo : null ,
metadataTags : null ,
formatInfo : null ,
audioBuffer : null ,
waveformPcm : null ,
waveformChannels : 0 ,
bpmRaw : 0 ,
isPlaying : false ,
playOffset : 0 ,
playStartTime : 0 ,
animFrameId : null ,
source : null ,
analyser : null ,
waveformCache : null ,
holdPeaks : null ,
alltimePeaks : null ,
peakBinCount : 0 ,
cutoffMaxHz : 0 ,
cutoffHold : false ,
windowType : 'hanning' ,
liveHover : null ,
init ( ) {
const fileInput = document . getElementById ( 'fileInput' ) ;
const loadBtn = document . getElementById ( 'loadBtn' ) ;
loadBtn . addEventListener ( 'click' , ( ) => fileInput . click ( ) ) ;
fileInput . addEventListener ( 'change' , ( e ) => {
if ( e . target . files . length > 0 ) this . loadFile ( e . target . files [ 0 ] ) ;
} ) ;
this . initBPMButtons ( ) ;
this . initPlayer ( ) ;
this . initLiveHover ( ) ;
const vh = window . innerHeight ;
this . waveformHeight = Math . min ( 240 , Math . round ( vh * 0.3 ) ) ;
this . spectrumHeight = Math . min ( 280 , Math . round ( vh * 0.35 ) ) ;
this . renderWaveform ( ) ;
this . renderLiveSpectrum ( ) ;
} ,
async loadFile ( file ) {
if ( this . isPlaying ) this . stopPlayback ( ) ;
this . stopSource ( ) ;
this . holdPeaks = null ;
this . alltimePeaks = null ;
this . peakBinCount = 0 ;
const validExts = [ 'mp3' , 'flac' , 'wav' , 'aiff' , 'aif' , 'ogg' , 'opus' , 'm4a' , 'wma' , 'ac3' , 'eac3' , 'aac' , 'alac' , 'wv' , 'mp2' , 'mp1' , 'ape' , 'dsf' , 'dff' ] ;
const ext = file . name . split ( '.' ) . pop ( ) . toLowerCase ( ) ;
if ( ! validExts . includes ( ext ) ) {
this . showError ( ` Unsupported format: . ${ ext } ` ) ;
return ;
}
this . showLoading ( ` Decoding ${ file . name } ... ` ) ;
document . getElementById ( 'fileName' ) . textContent = file . name ;
try {
const arrayBuffer = await file . arrayBuffer ( ) ;
const codecMap = {
'mp3' : 'MP3' , 'aac' : 'AAC' , 'm4a' : 'AAC' , 'ogg' : 'Vorbis' , 'opus' : 'Opus' ,
'wma' : 'WMA' , 'ac3' : 'AC3' , 'eac3' : 'EAC3' , 'mp2' : 'MP2' , 'mp1' : 'MP1' ,
'flac' : 'FLAC' , 'wav' : 'PCM' , 'aiff' : 'AIFF' , 'aif' : 'AIFF' , 'alac' : 'ALAC' ,
'wv' : 'WavPack' , 'ape' : 'APE' , 'dsf' : 'DSF' , 'dff' : 'DFF'
} ;
let codecName = codecMap [ ext ] || ext ;
if ( ! this . audioContext )
this . audioContext = new ( window . AudioContext || window . webkitAudioContext ) ( ) ;
const audioBuffer = await this . audioContext . decodeAudioData ( arrayBuffer . slice ( 0 ) ) ;
this . fileInfo = { name : file . name , codec : codecName , size : file . size } ;
this . audioBuffer = audioBuffer ;
this . metadataTags = Metadata . parse ( arrayBuffer , file . name ) ;
this . formatInfo = Metadata . getFormatInfo ( arrayBuffer , codecName ) ;
this . showLoading ( 'Running spectral analysis...' ) ;
await this . analyze ( audioBuffer , codecName ) ;
} catch ( err ) {
this . showError ( ` Failed to decode: ${ err . message } ` ) ;
} finally {
const lov = document . getElementById ( 'loadingOverlay' ) ;
if ( lov ) lov . style . display = 'none' ;
}
} ,
async analyze ( audioBuffer , codecName ) {
const sampleRate = audioBuffer . sampleRate ;
const channels = audioBuffer . numberOfChannels ;
const duration = audioBuffer . duration ;
const maxSecs = 120 ;
const fftSize = 4096 ;
const totalSamples = Math . min ( Math . floor ( sampleRate * maxSecs ) , audioBuffer . length ) ;
const interleaved = new Float32Array ( totalSamples * channels ) ;
for ( let ch = 0 ; ch < channels ; ch ++ ) {
const chData = audioBuffer . getChannelData ( ch ) ;
const len = Math . min ( chData . length , totalSamples ) ;
for ( let i = 0 ; i < len ; i ++ )
interleaved [ i * channels + ch ] = chData [ i ] ;
}
this . waveformPcm = interleaved ;
this . waveformChannels = channels ;
const bitrateKbps = duration > 0
? ( this . fileInfo . size * 8 ) / ( duration * 1000 )
: 0 ;
document . getElementById ( 'fileFormat' ) . textContent = codecName ;
document . getElementById ( 'fileSampleRate' ) . textContent = ` ${ sampleRate } Hz ` ;
const stereoMode = this . formatInfo && this . formatInfo . stereoMode ;
document . getElementById ( 'fileChannels' ) . textContent = ` ${ channels } ` ;
const smEl = document . getElementById ( 'fileStereoMode' ) ;
if ( stereoMode ) {
smEl . textContent = stereoMode ;
smEl . style . display = 'inline' ;
} else {
smEl . textContent = '' ;
smEl . style . display = 'none' ;
}
document . getElementById ( 'fileDuration' ) . textContent = ` ${ duration . toFixed ( 1 ) } s ` ;
const brText = bitrateKbps > 0 ? ` ${ bitrateKbps . toFixed ( 0 ) } kbps ` : '—' ;
const brMode = this . formatInfo && this . formatInfo . bitrateMode ;
document . getElementById ( 'fileBitrate' ) . textContent = brText ;
const brmEl = document . getElementById ( 'fileBitrateMode' ) ;
if ( brMode ) {
brmEl . textContent = brMode ;
brmEl . style . display = 'inline' ;
} else {
brmEl . textContent = '' ;
brmEl . style . display = 'none' ;
}
document . getElementById ( 'fileSize' ) . textContent = this . formatSize ( this . fileInfo . size ) ;
this . bpmRaw = 0 ;
document . getElementById ( 'fileBPM' ) . textContent = 'Analyzing...' ;
await this . sleep ( 10 ) ;
const result = Analyzer . analyzeAudio ( interleaved , sampleRate , channels , fftSize ) ;
if ( ! result ) {
this . showError ( 'File too short for analysis.' ) ;
return ;
}
document . getElementById ( 'windowsCount' ) . textContent = ` ${ result . count } ` ;
document . getElementById ( 'fftSize' ) . textContent = ` ${ fftSize } ` ;
this . bpmRaw = Analyzer . detectBPM ( interleaved , sampleRate , channels ) ;
this . updateBPMDisplay ( ) ;
const lossyCodecs = [ 'MP3' , 'AAC' , 'Vorbis' , 'Opus' , 'WMA' , 'AC3' , 'EAC3' , 'MP2' , 'MP1' ] ;
const isLossy = lossyCodecs . includes ( codecName ) ;
const detectResult = Analyzer . detectCutoff ( result , - 50 , 0.5 ) ;
const nyquist = sampleRate / 2.0 ;
const effectiveCutoff = ( detectResult . extendedCutoff > detectResult . cutoff && detectResult . noiseDb >= - 100.0 )
? detectResult . extendedCutoff : detectResult . cutoff ;
const effCutoffRatio = effectiveCutoff / nyquist ;
let peakDb = - 200 ;
if ( detectResult . peak > 1e-12 )
peakDb = 20.0 * Math . log10 ( detectResult . peak ) ;
const confidence = Analyzer . computeConfidence (
isLossy ? effCutoffRatio : detectResult . cutoffRatio ,
detectResult . steepness , detectResult . roughness ,
detectResult . bandRatio , isLossy ) ;
let upscaled = false ;
let sourceHint = '' ;
if ( isLossy ) {
let expectedMin = 0.90 ;
if ( detectResult . noiseDb < - 80.0 ) expectedMin -= 0.05 ;
if ( effCutoffRatio > 0 && effCutoffRatio < expectedMin - 0.08 )
upscaled = true ;
if ( effCutoffRatio < 0.70 ) sourceHint = '≤ 64 kbps source' ;
else if ( effCutoffRatio < 0.80 ) sourceHint = '96– 128 kbps source' ;
else if ( effCutoffRatio < 0.88 ) sourceHint = '128– 192 kbps source' ;
}
this . renderResults ( result , detectResult , peakDb , isLossy , upscaled , sourceHint , confidence , effectiveCutoff , effCutoffRatio , sampleRate ) ;
} ,
renderResults ( result , detectResult , peakDb , isLossy , upscaled , sourceHint , confidence , effectiveCutoff , effCutoffRatio , sampleRate ) {
const lov = document . getElementById ( 'loadingOverlay' ) ;
if ( lov ) lov . style . display = 'none' ;
this . renderMetadata ( ) ;
this . renderWaveform ( sampleRate ) ;
this . renderMetrics ( detectResult , peakDb , effectiveCutoff , effCutoffRatio , sampleRate ) ;
this . renderDecision ( detectResult , isLossy , upscaled , sourceHint , confidence ) ;
this . renderChart ( result , detectResult , effectiveCutoff , sampleRate ) ;
this . renderDataTable ( result , detectResult , peakDb , isLossy , upscaled , confidence , effectiveCutoff , sampleRate ) ;
} ,
renderWaveform ( sampleRate ) {
const canvas = document . getElementById ( 'waveformChart' ) ;
if ( ! canvas ) return ;
const dpr = window . devicePixelRatio ;
const ctx = canvas . getContext ( '2d' ) ;
const parentW = canvas . parentElement . clientWidth ;
canvas . width = parentW * dpr ;
canvas . height = this . waveformHeight * dpr ;
canvas . style . width = parentW + 'px' ;
canvas . style . height = this . waveformHeight + 'px' ;
const W = parentW ;
const H = this . waveformHeight ;
if ( ! this . waveformPcm ) return ;
const pad = { top : 22 , right : 20 , bottom : 24 , left : 68 } ;
const chartW = W - pad . left - pad . right ;
const chartH = H - pad . top - pad . bottom ;
const midY = pad . top + chartH / 2 ;
ctx . fillStyle = '#0d0d1a' ;
ctx . fillRect ( 0 , 0 , W , H ) ;
const channels = this . waveformChannels ;
const data = this . waveformPcm ;
const totalFrames = data . length / channels ;
const samplesPerPixel = Math . max ( 1 , Math . ceil ( totalFrames / chartW ) ) ;
const env = new Float32Array ( chartW * 2 ) ;
let globalPeak = 0 ;
for ( let x = 0 ; x < chartW ; x ++ ) {
let min = Infinity , max = - Infinity ;
const start = x * samplesPerPixel * channels ;
const end = Math . min ( ( x + 1 ) * samplesPerPixel * channels , data . length ) ;
for ( let i = start ; i < end ; i += channels ) {
let sum = 0 ;
for ( let ch = 0 ; ch < channels ; ch ++ )
sum += data [ i + ch ] ;
const s = sum / channels ;
if ( s < min ) min = s ;
if ( s > max ) max = s ;
}
if ( min === Infinity ) { min = 0 ; max = 0 ; }
env [ x * 2 ] = min ;
env [ x * 2 + 1 ] = max ;
const absPeak = Math . max ( Math . abs ( min ) , Math . abs ( max ) ) ;
if ( absPeak > globalPeak ) globalPeak = absPeak ;
}
const norm = globalPeak > 1e-12 ? globalPeak / 0.9 : 1 ;
const drawWaveformTo = ( c ) => {
const cx = c . getContext ( '2d' ) ;
cx . scale ( dpr , dpr ) ;
cx . fillStyle = '#1a1a2e' ;
cx . fillRect ( 0 , 0 , W , H ) ;
cx . beginPath ( ) ;
cx . moveTo ( pad . left , midY ) ;
for ( let x = 0 ; x < chartW ; x ++ ) {
const min = env [ x * 2 ] / norm ;
const max = env [ x * 2 + 1 ] / norm ;
cx . lineTo ( pad . left + x , midY - ( max * chartH / 2 ) ) ;
}
for ( let x = chartW - 1 ; x >= 0 ; x -- ) {
const min = env [ x * 2 ] / norm ;
const max = env [ x * 2 + 1 ] / norm ;
cx . lineTo ( pad . left + x , midY - ( min * chartH / 2 ) ) ;
}
cx . closePath ( ) ;
const grad = cx . createLinearGradient ( 0 , pad . top , 0 , pad . top + chartH ) ;
grad . addColorStop ( 0 , 'rgba(0, 188, 212, 0.5)' ) ;
grad . addColorStop ( 0.5 , 'rgba(0, 188, 212, 0.25)' ) ;
grad . addColorStop ( 1 , 'rgba(0, 188, 212, 0.5)' ) ;
cx . fillStyle = grad ;
cx . fill ( ) ;
cx . beginPath ( ) ;
cx . moveTo ( pad . left , midY ) ;
for ( let x = 0 ; x < chartW ; x ++ ) {
const max = env [ x * 2 + 1 ] / norm ;
cx . lineTo ( pad . left + x , midY - ( max * chartH / 2 ) ) ;
}
for ( let x = chartW - 1 ; x >= 0 ; x -- ) {
const min = env [ x * 2 ] / norm ;
cx . lineTo ( pad . left + x , midY - ( min * chartH / 2 ) ) ;
}
cx . closePath ( ) ;
cx . strokeStyle = '#00bcd4' ;
cx . lineWidth = 1 ;
cx . stroke ( ) ;
cx . strokeStyle = 'rgba(255,255,255,0.06)' ;
cx . lineWidth = 1 ;
cx . beginPath ( ) ;
cx . moveTo ( pad . left , midY ) ;
cx . lineTo ( pad . left + chartW , midY ) ;
cx . stroke ( ) ;
const duration = this . audioBuffer ? this . audioBuffer . duration : 0 ;
cx . fillStyle = 'rgba(255,255,255,0.4)' ;
cx . font = '10px "SF Mono", "Fira Code", monospace' ;
cx . textAlign = 'center' ;
cx . textBaseline = 'top' ;
const numLabels = Math . max ( 2 , Math . floor ( chartW / 100 ) ) ;
for ( let i = 0 ; i <= numLabels ; i ++ ) {
const t = ( i / numLabels ) * duration ;
const x = pad . left + ( i / numLabels ) * chartW ;
const label = t >= 60 ? ` ${ ( t / 60 ) . toFixed ( 0 ) } m ${ ( t % 60 ) . toFixed ( 0 ) } s ` : ` ${ t . toFixed ( 1 ) } s ` ;
cx . fillText ( label , x , pad . top + chartH + 4 ) ;
}
cx . textAlign = 'right' ;
cx . textBaseline = 'middle' ;
cx . fillStyle = 'rgba(255,255,255,0.3)' ;
for ( const amp of [ - 1 , - 0.5 , 0 , 0.5 , 1 ] ) {
const y = midY - ( amp * chartH / 2 ) ;
if ( y >= pad . top && y <= pad . top + chartH ) {
cx . fillText ( amp . toFixed ( 1 ) , pad . left - 16 , y ) ;
}
}
cx . fillStyle = 'rgba(255,255,255,0.35)' ;
cx . font = '10px "SF Mono", "Fira Code", monospace' ;
cx . textAlign = 'center' ;
cx . textBaseline = 'top' ;
cx . fillText ( 'Time' , pad . left + chartW / 2 , pad . top + chartH + 18 ) ;
cx . save ( ) ;
cx . translate ( 8 , pad . top + chartH / 2 ) ;
cx . rotate ( - Math . PI / 2 ) ;
cx . textAlign = 'center' ;
cx . textBaseline = 'middle' ;
cx . fillText ( 'Amplitude' , 0 , 0 ) ;
cx . restore ( ) ;
} ;
drawWaveformTo ( canvas ) ;
this . waveformCache = document . createElement ( 'canvas' ) ;
this . waveformCache . width = canvas . width ;
this . waveformCache . height = canvas . height ;
drawWaveformTo ( this . waveformCache ) ;
const phCanvas = document . getElementById ( 'waveformPlayhead' ) ;
if ( phCanvas ) {
phCanvas . width = canvas . width ;
phCanvas . height = canvas . height ;
phCanvas . style . width = canvas . style . width ;
phCanvas . style . height = canvas . style . height ;
}
const container = canvas . parentElement ;
container . style . cursor = 'pointer' ;
container . addEventListener ( 'click' , ( e ) => {
if ( ! this . audioBuffer ) return ;
const rect = canvas . getBoundingClientRect ( ) ;
const pad = { left : 68 , right : 20 } ;
const chartW = rect . width - pad . left - pad . right ;
const x = e . clientX - rect . left ;
const relX = ( x - pad . left ) / chartW ;
const time = Math . max ( 0 , Math . min ( relX * this . audioBuffer . duration , this . audioBuffer . duration ) ) ;
this . seekAudio ( time ) ;
if ( ! this . isPlaying ) this . togglePlay ( ) ;
} ) ;
document . getElementById ( 'playTime' ) . textContent = ` 0:00.0 / 0:00.0 ` ;
} ,
initBPMButtons ( ) {
document . getElementById ( 'bpmHalf' ) . addEventListener ( 'click' , ( ) => {
if ( this . bpmRaw > 0 ) { this . bpmRaw /= 2 ; this . updateBPMDisplay ( ) ; }
} ) ;
document . getElementById ( 'bpmDouble' ) . addEventListener ( 'click' , ( ) => {
if ( this . bpmRaw > 0 ) { this . bpmRaw *= 2 ; this . updateBPMDisplay ( ) ; }
} ) ;
} ,
updateBPMDisplay ( ) {
document . getElementById ( 'fileBPM' ) . textContent = this . bpmRaw > 0 ? ` ${ this . bpmRaw . toFixed ( 2 ) } ` : '—' ;
} ,
initPlayer ( ) {
document . getElementById ( 'playBtn' ) . addEventListener ( 'click' , ( ) => this . togglePlay ( ) ) ;
document . getElementById ( 'stopBtn' ) . addEventListener ( 'click' , ( ) => this . stopPlayback ( ) ) ;
const resetBtn = document . getElementById ( 'resetBtn' ) ;
if ( resetBtn ) resetBtn . addEventListener ( 'click' , ( ) => this . resetPeaks ( ) ) ;
const holdBtn = document . getElementById ( 'holdBtn' ) ;
if ( holdBtn ) holdBtn . addEventListener ( 'click' , ( ) => this . toggleCutoffHold ( ) ) ;
const winSel = document . getElementById ( 'windowSelect' ) ;
if ( winSel ) winSel . addEventListener ( 'change' , ( ) => {
this . windowType = winSel . value ;
this . renderLiveSpectrum ( ) ;
} ) ;
} ,
togglePlay ( ) {
if ( ! this . audioBuffer ) return ;
if ( this . isPlaying ) {
this . playOffset += this . audioContext . currentTime - this . playStartTime ;
this . stopSource ( ) ;
this . isPlaying = false ;
document . getElementById ( 'playBtn' ) . textContent = '▶' ;
if ( this . animFrameId ) { cancelAnimationFrame ( this . animFrameId ) ; this . animFrameId = null ; }
this . drawPlayhead ( this . playOffset ) ;
} else {
if ( this . audioContext . state === 'suspended' ) this . audioContext . resume ( ) ;
this . startSource ( this . playOffset ) ;
this . isPlaying = true ;
document . getElementById ( 'playBtn' ) . textContent = '⏸' ;
this . animFrameId = requestAnimationFrame ( ( ) => this . playbackLoop ( ) ) ;
}
} ,
stopPlayback ( ) {
if ( this . animFrameId ) { cancelAnimationFrame ( this . animFrameId ) ; this . animFrameId = null ; }
this . stopSource ( ) ;
this . isPlaying = false ;
this . playOffset = 0 ;
document . getElementById ( 'playBtn' ) . textContent = '▶' ;
this . drawPlayhead ( 0 ) ;
document . getElementById ( 'playTime' ) . textContent = '0:00.0 / 0:00.0' ;
} ,
startSource ( offset ) {
this . stopSource ( ) ;
this . source = this . audioContext . createBufferSource ( ) ;
this . source . buffer = this . audioBuffer ;
this . analyser = this . audioContext . createAnalyser ( ) ;
this . analyser . fftSize = 2048 ;
this . analyser . smoothingTimeConstant = 0.85 ;
this . source . connect ( this . analyser ) ;
this . analyser . connect ( this . audioContext . destination ) ;
this . playStartTime = this . audioContext . currentTime ;
this . source . start ( 0 , offset ) ;
this . peakBinCount = this . analyser . frequencyBinCount ;
if ( ! this . holdPeaks || this . holdPeaks . length !== this . peakBinCount ) {
this . holdPeaks = new Float32Array ( this . peakBinCount ) ;
}
if ( ! this . alltimePeaks || this . alltimePeaks . length !== this . peakBinCount ) {
this . alltimePeaks = new Float32Array ( this . peakBinCount ) ;
}
} ,
stopSource ( ) {
if ( this . source ) {
try { this . source . stop ( ) ; } catch ( e ) { }
try { this . source . disconnect ( ) ; } catch ( e ) { }
this . source = null ;
}
if ( this . analyser ) {
try { this . analyser . disconnect ( ) ; } catch ( e ) { }
this . analyser = null ;
}
} ,
seekAudio ( time ) {
if ( ! this . audioBuffer ) return ;
this . playOffset = Math . max ( 0 , Math . min ( time , this . audioBuffer . duration ) ) ;
this . drawPlayhead ( this . playOffset ) ;
if ( this . isPlaying ) {
this . startSource ( this . playOffset ) ;
}
} ,
playbackLoop ( ) {
if ( ! this . isPlaying ) return ;
const elapsed = this . audioContext . currentTime - this . playStartTime ;
const pos = this . playOffset + elapsed ;
if ( pos >= this . audioBuffer . duration ) {
this . stopPlayback ( ) ;
return ;
}
this . drawPlayhead ( pos ) ;
this . renderLiveSpectrum ( ) ;
this . updateLiveMetrics ( ) ;
this . updatePlayTime ( pos ) ;
this . animFrameId = requestAnimationFrame ( ( ) => this . playbackLoop ( ) ) ;
} ,
drawPlayhead ( pos ) {
const canvas = document . getElementById ( 'waveformChart' ) ;
if ( ! canvas || ! this . waveformCache || ! this . audioBuffer ) return ;
const dpr = window . devicePixelRatio ;
const W = canvas . width / dpr ;
const H = canvas . height / dpr ;
const ctx = canvas . getContext ( '2d' ) ;
ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 ) ;
ctx . clearRect ( 0 , 0 , W , H ) ;
ctx . drawImage ( this . waveformCache , 0 , 0 , canvas . width , canvas . height , 0 , 0 , W , H ) ;
const pad = { left : 68 , right : 20 , top : 22 , bottom : 24 } ;
const chartW = W - pad . left - pad . right ;
const ratio = this . audioBuffer . duration > 0 ? pos / this . audioBuffer . duration : 0 ;
const x = pad . left + ratio * chartW ;
ctx . beginPath ( ) ;
ctx . moveTo ( x , pad . top ) ;
ctx . lineTo ( x , H - pad . bottom ) ;
ctx . strokeStyle = '#ff5252' ;
ctx . lineWidth = 2 ;
ctx . stroke ( ) ;
} ,
updatePlayTime ( pos ) {
const dur = this . audioBuffer ? this . audioBuffer . duration : 0 ;
const fmt = ( t ) => {
if ( ! t || t < 0 ) return '0:00.0' ;
const m = Math . floor ( t / 60 ) ;
const s = Math . floor ( t % 60 ) ;
const ds = Math . floor ( ( t - Math . floor ( t ) ) * 10 ) ;
return ` ${ m } : ${ s . toString ( ) . padStart ( 2 , '0' ) } . ${ ds } ` ;
} ;
document . getElementById ( 'playTime' ) . textContent = ` ${ fmt ( pos ) } / ${ fmt ( dur ) } ` ;
} ,
renderLiveSpectrum ( ) {
const canvas = document . getElementById ( 'liveSpectrum' ) ;
if ( ! canvas ) return ;
const dpr = window . devicePixelRatio ;
const parentW = canvas . parentElement . clientWidth ;
canvas . width = parentW * dpr ;
canvas . height = this . spectrumHeight * dpr ;
canvas . style . width = parentW + 'px' ;
canvas . style . height = this . spectrumHeight + 'px' ;
const ctx = canvas . getContext ( '2d' ) ;
ctx . scale ( dpr , dpr ) ;
const W = parentW ;
const H = this . spectrumHeight ;
const pad = { top : 22 , right : 20 , bottom : 22 , left : 50 } ;
const chartW = W - pad . left - pad . right ;
const chartH = H - pad . top - pad . bottom ;
if ( ! this . analyser ) {
const ov = document . getElementById ( 'liveSpectrumOverlay' ) ;
if ( ov ) { ov . width = 0 ; ov . height = 0 ; }
return ;
}
ctx . fillStyle = '#1a1a2e' ;
ctx . fillRect ( 0 , 0 , W , H ) ;
ctx . save ( ) ;
ctx . strokeStyle = 'rgba(255,255,255,0.06)' ;
ctx . lineWidth = 1 ;
ctx . fillStyle = 'rgba(255,255,255,0.25)' ;
ctx . font = '9px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'right' ;
ctx . textBaseline = 'middle' ;
for ( const db of [ 0 , - 6 , - 12 , - 24 , - 36 ] ) {
const val = Math . pow ( 10 , db / 20 ) ;
const y = pad . top + chartH - val * chartH ;
if ( y >= pad . top && y <= pad . top + chartH ) {
ctx . fillText ( ` ${ db } ` , pad . left - 6 , y ) ;
ctx . beginPath ( ) ;
ctx . moveTo ( pad . left , y ) ;
ctx . lineTo ( pad . left + chartW , y ) ;
ctx . stroke ( ) ;
}
}
ctx . restore ( ) ;
const data = new Uint8Array ( this . analyser . frequencyBinCount ) ;
this . analyser . getByteFrequencyData ( data ) ;
const len = data . length ;
ctx . beginPath ( ) ;
ctx . moveTo ( pad . left , pad . top + chartH ) ;
const scaleVal = ( raw ) => {
const r = raw / 255 ;
switch ( this . windowType ) {
2026-07-06 18:09:24 +02:00
case 'none' : return r ;
case 'hanning' : return Math . min ( 1 , Math . sqrt ( r ) * 0.85 ) ;
case 'hamming' : return Math . min ( 1 , Math . pow ( r , 0.48 ) * 0.85 ) ;
2026-07-05 00:44:08 +02:00
case 'blackman' : return Math . min ( 1 , Math . pow ( r , 0.4 ) * 0.85 ) ;
2026-07-06 18:09:24 +02:00
case 'bartlett' : return Math . min ( 1 , Math . pow ( r , 0.55 ) * 0.85 ) ;
case 'gaussian' : return Math . min ( 1 , Math . pow ( r , 0.43 ) * 0.85 ) ;
case 'kaiser' : return Math . min ( 1 , Math . pow ( r , 0.33 ) * 0.85 ) ;
case 'flatTop' : return Math . min ( 1 , Math . pow ( r , 0.3 ) * 0.85 ) ;
default : return Math . min ( 1 , Math . sqrt ( r ) * 0.85 ) ;
2026-07-05 00:44:08 +02:00
}
} ;
for ( let x = 0 ; x < chartW ; x ++ ) {
const idx = Math . min ( Math . floor ( x * len / chartW ) , len - 1 ) ;
const val = scaleVal ( data [ idx ] ) ;
const y = pad . top + chartH - val * chartH ;
ctx . lineTo ( pad . left + x , y ) ;
}
ctx . lineTo ( pad . left + chartW , pad . top + chartH ) ;
ctx . closePath ( ) ;
const grad = ctx . createLinearGradient ( 0 , pad . top , 0 , pad . top + chartH ) ;
grad . addColorStop ( 0 , 'rgba(0, 188, 212, 0.4)' ) ;
grad . addColorStop ( 1 , 'rgba(0, 188, 212, 0.05)' ) ;
ctx . fillStyle = grad ;
ctx . fill ( ) ;
ctx . beginPath ( ) ;
for ( let x = 0 ; x < chartW ; x ++ ) {
const idx = Math . min ( Math . floor ( x * len / chartW ) , len - 1 ) ;
const val = scaleVal ( data [ idx ] ) ;
const y = pad . top + chartH - val * chartH ;
if ( x === 0 ) ctx . moveTo ( pad . left , y ) ;
else ctx . lineTo ( pad . left + x , y ) ;
}
ctx . strokeStyle = '#00bcd4' ;
ctx . lineWidth = 1.5 ;
ctx . stroke ( ) ;
if ( this . holdPeaks ) {
2026-07-06 18:09:41 +02:00
const decaySel = document . getElementById ( 'peakHoldDecay' ) ;
const decayMap = { off : 0 , slow : 0.005 , medium : 0.015 , fast : 0.03 } ;
const decay = decaySel ? ( decayMap [ decaySel . value ] ? ? 0.015 ) : 0.015 ;
2026-07-05 00:44:08 +02:00
ctx . beginPath ( ) ;
let started = false ;
for ( let x = 0 ; x < chartW ; x ++ ) {
const idx = Math . min ( Math . floor ( x * len / chartW ) , len - 1 ) ;
const raw = data [ idx ] / 255 ;
if ( raw > this . holdPeaks [ idx ] ) this . holdPeaks [ idx ] = raw ;
else this . holdPeaks [ idx ] = Math . max ( 0 , this . holdPeaks [ idx ] - decay ) ;
const y = pad . top + chartH - scaleVal ( this . holdPeaks [ idx ] * 255 ) * chartH ;
if ( ! started ) { ctx . moveTo ( pad . left + x , y ) ; started = true ; }
else ctx . lineTo ( pad . left + x , y ) ;
}
ctx . strokeStyle = 'rgba(255,255,255,0.85)' ;
ctx . lineWidth = 1.5 ;
ctx . stroke ( ) ;
}
if ( this . alltimePeaks ) {
if ( this . isPlaying ) {
for ( let i = 0 ; i < len ; i ++ ) {
const raw = data [ i ] / 255 ;
if ( raw > this . alltimePeaks [ i ] ) this . alltimePeaks [ i ] = raw ;
}
}
ctx . beginPath ( ) ;
let started = false ;
for ( let x = 0 ; x < chartW ; x ++ ) {
const idx = Math . min ( Math . floor ( x * len / chartW ) , len - 1 ) ;
const y = pad . top + chartH - scaleVal ( this . alltimePeaks [ idx ] * 255 ) * chartH ;
if ( ! started ) { ctx . moveTo ( pad . left + x , y ) ; started = true ; }
else ctx . lineTo ( pad . left + x , y ) ;
}
ctx . strokeStyle = 'rgba(255, 183, 77, 0.5)' ;
ctx . lineWidth = 1.5 ;
ctx . stroke ( ) ;
}
let maxVal = 0 ;
for ( let i = 0 ; i < len ; i ++ ) {
if ( data [ i ] > maxVal ) maxVal = data [ i ] ;
}
const threshold = Math . max ( 5 , maxVal * 0.08 ) ;
let cutoffBin = 0 ;
for ( let i = len - 1 ; i >= 0 ; i -- ) {
if ( data [ i ] >= threshold ) { cutoffBin = i ; break ; }
}
const sr = this . audioBuffer ? this . audioBuffer . sampleRate : 44100 ;
const nyquist = sr / 2 ;
const cutoffHz = ( cutoffBin / len ) * nyquist ;
if ( cutoffHz > this . cutoffMaxHz ) this . cutoffMaxHz = cutoffHz ;
2026-07-06 18:10:05 +02:00
const falloffSel = document . getElementById ( 'cutoffFalloff' ) ;
if ( falloffSel && falloffSel . value !== 'off' && this . isPlaying && cutoffHz < this . cutoffMaxHz ) {
const falloffMap = { slow : 0.01 , medium : 0.03 , fast : 0.08 } ;
const rate = falloffMap [ falloffSel . value ] ? ? 0 ;
const gap = this . cutoffMaxHz - cutoffHz ;
if ( gap > 1 ) this . cutoffMaxHz -= gap * rate ;
}
2026-07-05 00:44:08 +02:00
const displayHz = this . cutoffHold && this . cutoffMaxHz > 0 ? this . cutoffMaxHz : cutoffHz ;
const displayBin = Math . round ( ( displayHz / nyquist ) * len ) ;
if ( displayBin > 0 ) {
const cx = pad . left + ( displayBin / len ) * chartW ;
ctx . beginPath ( ) ;
ctx . moveTo ( cx , pad . top ) ;
ctx . lineTo ( cx , pad . top + chartH ) ;
ctx . strokeStyle = this . cutoffHold ? 'rgba(255, 82, 82, 0.95)' : 'rgba(255, 82, 82, 0.7)' ;
ctx . lineWidth = this . cutoffHold ? 2 : 1.5 ;
ctx . setLineDash ( this . cutoffHold ? [ 4 , 3 ] : [ ] ) ;
ctx . stroke ( ) ;
ctx . setLineDash ( [ ] ) ;
ctx . fillStyle = this . cutoffHold ? '#ff8a80' : '#ff5252' ;
ctx . font = 'bold 10px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'center' ;
ctx . textBaseline = 'bottom' ;
const label = this . cutoffHold ? ` Max ${ this . cutoffMaxHz . toFixed ( 0 ) } Hz ` : ` ${ cutoffHz . toFixed ( 0 ) } Hz ` ;
ctx . fillText ( label , cx , pad . top - 1 ) ;
}
ctx . fillStyle = 'rgba(255,255,255,0.3)' ;
ctx . font = '9px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'center' ;
ctx . textBaseline = 'top' ;
const freqLabels = [ 20 , 100 , 1000 , 10000 , 20000 ] ;
for ( const f of freqLabels ) {
if ( f >= nyquist ) break ;
const x = pad . left + ( f / nyquist ) * chartW ;
ctx . fillText ( f >= 1000 ? ` ${ ( f / 1000 ) . toFixed ( 0 ) } k ` : ` ${ f } ` , x , pad . top + chartH + 4 ) ;
}
ctx . save ( ) ;
ctx . translate ( 10 , pad . top + chartH / 2 ) ;
ctx . rotate ( - Math . PI / 2 ) ;
ctx . textAlign = 'center' ;
ctx . textBaseline = 'middle' ;
ctx . fillStyle = 'rgba(255,255,255,0.3)' ;
ctx . font = '9px "SF Mono", "Fira Code", monospace' ;
ctx . fillText ( 'dBFS' , 0 , 0 ) ;
ctx . restore ( ) ;
ctx . fillStyle = 'rgba(255,255,255,0.12)' ;
ctx . font = '9px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'right' ;
ctx . textBaseline = 'bottom' ;
ctx . fillText ( 'Realtime FFT' , pad . left + chartW , pad . top - 6 ) ;
} ,
updateLiveMetrics ( ) {
if ( ! this . analyser ) {
document . getElementById ( 'livePeak' ) . textContent = '—' ;
document . getElementById ( 'liveCutoff' ) . textContent = '—' ;
return ;
}
const data = new Uint8Array ( this . analyser . frequencyBinCount ) ;
this . analyser . getByteFrequencyData ( data ) ;
const len = data . length ;
const sr = this . audioBuffer ? this . audioBuffer . sampleRate : 44100 ;
const nyquist = sr / 2 ;
let maxVal = 0 ;
let cutoffBin = 0 ;
for ( let i = 0 ; i < len ; i ++ ) {
if ( data [ i ] > maxVal ) maxVal = data [ i ] ;
}
const threshold = Math . max ( 5 , maxVal * 0.08 ) ;
for ( let i = len - 1 ; i >= 0 ; i -- ) {
if ( data [ i ] >= threshold ) { cutoffBin = i ; break ; }
}
const peakDb = maxVal > 0 ? ( maxVal / 255 ) * 100 - 100 : - 100 ;
const cutoffHz = ( cutoffBin / len ) * nyquist ;
document . getElementById ( 'livePeak' ) . textContent = ` ${ peakDb . toFixed ( 1 ) } dBFS ` ;
document . getElementById ( 'liveCutoff' ) . textContent = cutoffHz > 0 ? ` ${ cutoffHz . toFixed ( 0 ) } Hz ` : '—' ;
} ,
resetPeaks ( ) {
if ( this . holdPeaks ) this . holdPeaks . fill ( 0 ) ;
if ( this . alltimePeaks ) this . alltimePeaks . fill ( 0 ) ;
this . cutoffMaxHz = 0 ;
this . renderLiveSpectrum ( ) ;
} ,
initLiveHover ( ) {
const canvas = document . getElementById ( 'liveSpectrum' ) ;
if ( ! canvas ) return ;
canvas . addEventListener ( 'mousemove' , ( e ) => {
const rect = canvas . getBoundingClientRect ( ) ;
const x = e . clientX - rect . left ;
const y = e . clientY - rect . top ;
const sr = this . audioBuffer ? this . audioBuffer . sampleRate : 44100 ;
const nyquist = sr / 2 ;
const len = this . analyser ? this . analyser . frequencyBinCount : 0 ;
if ( ! len ) { this . liveHover = null ; this . drawLiveHoverOverlay ( ) ; return ; }
const pad = { left : 50 , right : 20 , top : 22 , bottom : 22 } ;
const W = rect . width ;
const H = canvas . offsetHeight || this . spectrumHeight ;
const chartW = W - pad . left - pad . right ;
const chartH = H - pad . top - pad . bottom ;
const relX = ( x - pad . left ) / chartW ;
const idx = Math . min ( Math . floor ( relX * len ) , len - 1 ) ;
const relY = 1 - ( y - pad . top ) / chartH ;
const freq = ( idx / len ) * nyquist ;
const amp = Math . max ( 0 , Math . min ( 1 , relY ) ) ;
const data = new Uint8Array ( len ) ;
if ( this . analyser ) this . analyser . getByteFrequencyData ( data ) ;
const actualVal = idx >= 0 && idx < len ? data [ idx ] / 255 : 0 ;
const actualY = pad . top + chartH - actualVal * chartH ;
this . liveHover = { x : pad . left + ( idx / len ) * chartW , y : actualY , freq , amp : actualVal , idx } ;
this . drawLiveHoverOverlay ( ) ;
} ) ;
canvas . addEventListener ( 'mouseleave' , ( ) => {
this . liveHover = null ;
this . drawLiveHoverOverlay ( ) ;
} ) ;
} ,
drawLiveHoverOverlay ( ) {
const main = document . getElementById ( 'liveSpectrum' ) ;
const overlay = document . getElementById ( 'liveSpectrumOverlay' ) ;
if ( ! main || ! overlay ) return ;
const dpr = window . devicePixelRatio ;
const parentW = main . parentElement . clientWidth ;
overlay . width = parentW * dpr ;
overlay . height = ( main . offsetHeight || this . spectrumHeight ) * dpr ;
overlay . style . width = parentW + 'px' ;
overlay . style . height = ( main . offsetHeight || this . spectrumHeight ) + 'px' ;
const ctx = overlay . getContext ( '2d' ) ;
ctx . clearRect ( 0 , 0 , overlay . width , overlay . height ) ;
ctx . scale ( dpr , dpr ) ;
if ( ! this . liveHover ) return ;
const { x , y , freq , amp } = this . liveHover ;
ctx . beginPath ( ) ;
ctx . arc ( x , y , 4 , 0 , Math . PI * 2 ) ;
ctx . fillStyle = '#ff5252' ;
ctx . fill ( ) ;
ctx . strokeStyle = 'rgba(255,255,255,0.5)' ;
ctx . lineWidth = 1.5 ;
ctx . stroke ( ) ;
ctx . fillStyle = 'rgba(0,0,0,0.75)' ;
ctx . fillRect ( x + 10 , y - 22 , 120 , 32 ) ;
ctx . strokeStyle = 'rgba(255,255,255,0.15)' ;
ctx . lineWidth = 1 ;
ctx . strokeRect ( x + 10 , y - 22 , 120 , 32 ) ;
ctx . fillStyle = '#fff' ;
ctx . font = '10px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'left' ;
ctx . textBaseline = 'middle' ;
ctx . fillText ( ` ${ freq . toFixed ( 0 ) } Hz ` , x + 18 , y - 10 ) ;
ctx . fillStyle = '#00bcd4' ;
ctx . fillText ( ` ${ ( amp * 100 ) . toFixed ( 1 ) } % ` , x + 18 , y + 6 ) ;
} ,
toggleCutoffHold ( ) {
this . cutoffHold = ! this . cutoffHold ;
const btn = document . getElementById ( 'holdBtn' ) ;
if ( btn ) btn . classList . toggle ( 'active' , this . cutoffHold ) ;
if ( this . cutoffHold ) this . cutoffMaxHz = 0 ;
this . renderLiveSpectrum ( ) ;
} ,
renderMetadata ( ) {
const grid = document . getElementById ( 'metadataGrid' ) ;
const empty = document . getElementById ( 'metadataEmpty' ) ;
grid . innerHTML = '' ;
if ( ! this . metadataTags || this . metadataTags . length === 0 ) {
grid . style . display = 'none' ;
empty . style . display = 'block' ;
return ;
}
empty . style . display = 'none' ;
grid . style . display = 'grid' ;
for ( const tag of this . metadataTags ) {
const el = document . createElement ( 'div' ) ;
el . className = 'metadata-item' ;
const src = document . createElement ( 'span' ) ;
src . className = 'metadata-source' ;
src . textContent = tag . source ;
const key = document . createElement ( 'span' ) ;
key . className = 'metadata-key' ;
key . textContent = tag . key ;
const val = document . createElement ( 'span' ) ;
val . className = 'metadata-value' ;
val . textContent = tag . value ;
el . appendChild ( src ) ;
el . appendChild ( key ) ;
el . appendChild ( val ) ;
grid . appendChild ( el ) ;
}
} ,
renderMetrics ( dr , peakDb , effCutoff , effCutoffRatio , sampleRate ) {
document . getElementById ( 'mPeak' ) . textContent = ` ${ peakDb . toFixed ( 1 ) } dBFS ` ;
document . getElementById ( 'mCutoff' ) . textContent = ` ${ effCutoff . toFixed ( 0 ) } Hz ( ${ ( 100 * effCutoffRatio ) . toFixed ( 1 ) } %) ` ;
document . getElementById ( 'mSteepness' ) . textContent = ` ${ dr . steepness . toFixed ( 0 ) } Hz ` ;
document . getElementById ( 'mNoise' ) . textContent = ` ${ dr . noiseDb . toFixed ( 1 ) } dB ` ;
document . getElementById ( 'mRoughness' ) . textContent = ` ${ dr . roughness . toFixed ( 3 ) } ` ;
document . getElementById ( 'mBandRatio' ) . textContent = ` ${ dr . bandRatio . toFixed ( 3 ) } ` ;
} ,
renderDecision ( dr , isLossy , upscaled , sourceHint , confidence ) {
const el = document . getElementById ( 'decision' ) ;
const nyquist = 24000 ; /* approx, used for display only */
if ( isLossy ) {
if ( upscaled ) {
el . innerHTML = ` <span class="verdict upscaled">UPSCALED</span> — re-encoded from lower bitrate ` ;
this . renderFactors ( dr , isLossy , true , sourceHint ) ;
} else {
el . innerHTML = ` <span class="verdict native">NATIVE</span> — single encode at this bitrate ` ;
this . renderFactors ( dr , isLossy , false , '' ) ;
}
} else {
if ( dr . score ) {
el . innerHTML = ` <span class="verdict transcode">TRANSCODE</span> — lossy → lossless re-encode detected ` ;
} else {
el . innerHTML = ` <span class="verdict genuine">GENUINE</span> — likely native lossless ` ;
}
this . renderFactors ( dr , false , false , '' ) ;
}
const bar = document . getElementById ( 'confidenceBar' ) ;
const label = document . getElementById ( 'confidenceLabel' ) ;
const fillPct = Math . min ( 100 , Math . max ( 0 , confidence ) ) ;
bar . style . width = ` ${ fillPct } % ` ;
bar . textContent = ` ${ fillPct . toFixed ( 0 ) } % ` ;
let confLabel ;
if ( fillPct >= 85 ) confLabel = 'Very strong evidence' ;
else if ( fillPct >= 70 ) confLabel = 'Strong evidence' ;
else if ( fillPct >= 50 ) confLabel = 'Moderate evidence' ;
else if ( fillPct >= 30 ) confLabel = 'Weak evidence' ;
else confLabel = 'Borderline / inconclusive' ;
label . textContent = confLabel ;
} ,
renderFactors ( dr , isLossy , upscaled , sourceHint ) {
const el = document . getElementById ( 'factors' ) ;
let html = '<h3>Factor Analysis</h3><ul>' ;
if ( isLossy ) {
if ( upscaled ) {
html += ` <li title="Cutoff ratio compares the spectral cutoff to Nyquist frequency. A lossy file from a low-bitrate source has a cutoff well below what the current bitrate would normally allow. The ratio is checked against adaptive thresholds for the bitrate range.">cutoff_ratio= ${ dr . cutoffRatio . toFixed ( 3 ) } below expected for bitrate ✓</li> ` ;
if ( sourceHint ) html += ` <li class="hint" title="The cutoff ratio maps to estimated source bitrate ranges: • < 0.70 → ≤ 64 kbps source • 0.70-0.80 → 96-128 kbps source • 0.80-0.88 → 128-192 kbps source">→ Suggests ${ sourceHint } </li> ` ;
} else {
html += ` <li title="For a single-encode lossy file, the cutoff ratio should match the expected range for its bitrate. If the ratio falls within the natural range for that codec/bitrate, the file is classified as a native encode.">cutoff_ratio= ${ dr . cutoffRatio . toFixed ( 3 ) } within expected range ✓</li> ` ;
}
} else {
const fr = dr . cutoffRatio ;
let maxBwDisp ;
if ( fr < 0.50 ) maxBwDisp = 4000 ;
else if ( fr < 0.70 ) maxBwDisp = 3000 ;
else if ( fr < 0.80 ) maxBwDisp = 2000 ;
else if ( fr < 0.90 ) maxBwDisp = 1200 ;
else maxBwDisp = 500 ;
const primaryHit = ( fr >= 0.99 ) ? false : ( dr . maxBw !== undefined && dr . steepness < dr . maxBw ) ;
const secondaryApplies = ( fr > 0.80 ) ;
if ( fr >= 0.99 ) {
html += ` <li title="A cutoff_ratio ≥ 0.99 means the spectrum extends to the full Nyquist frequency with no detectable lowpass filter. This is typical of analog masters or high-quality digital recordings. When the spectrum is full, transition bandwidth cannot be measured, so this check is bypassed (neither pass nor fail).">① cutoff_ratio= ${ fr . toFixed ( 3 ) } ≥ 0.99 <span class="fail">✗</span> (full spectrum, no cutoff)</li> ` ;
} else {
if ( primaryHit ) {
html += ` <li title="PRIMARY CRITERION PASSED. The transition bandwidth ( ${ dr . steepness . toFixed ( 0 ) } Hz) is below the adaptive threshold ( ${ maxBwDisp } Hz) for this cutoff ratio range. A sharp transition means the spectrum drops unnaturally fast — a classic sign of a lossy encoder's lowpass filter. This strongly suggests the file was transcoded from a lossy source.">① cutoff_ratio= ${ fr . toFixed ( 3 ) } , transition_bw= ${ dr . steepness . toFixed ( 0 ) } Hz < ${ maxBwDisp } Hz threshold <span class="pass">✓</span></li> ` ;
html += ` <li class="hint" title="When the transition bandwidth is below threshold, it indicates the high-frequency cutoff is artificially sharp — a pattern introduced by lossy compression filters. This is the primary evidence for transcode detection.">→ Transition bandwidth indicates transcode</li> ` ;
} else {
html += ` <li title="PRIMARY CRITERION NOT MET. The transition bandwidth ( ${ dr . steepness . toFixed ( 0 ) } Hz) is at or above the adaptive threshold ( ${ maxBwDisp } Hz). A gradual high-frequency rolloff is consistent with natural audio or lossless encoding. No transcode signature detected from steepness alone.">① cutoff_ratio= ${ fr . toFixed ( 3 ) } , transition_bw= ${ dr . steepness . toFixed ( 0 ) } Hz ≥ ${ maxBwDisp } Hz <span class="fail">✗</span></li> ` ;
}
}
if ( secondaryApplies ) {
const r1 = 0.40 ;
const r2 = 0.30 ;
const r3 = 0.20 ;
const b1 = 0.90 ;
const b2 = 0.85 ;
let secHit = false ;
if ( dr . roughness > r1 ) {
html += ` <li title="SECONDARY CRITERION PASSED (strong). Roughness (CoV) measures spectral scalloping — the 'comb filter' pattern from lossy quantization. At > ${ r1 } , the spectrum is clearly uneven, confirming lossy encoding even when the cutoff ratio is high.">② roughness= ${ dr . roughness . toFixed ( 3 ) } > ${ r1 } <span class="pass">✓</span> (high roughness → transcode)</li> ` ;
secHit = true ;
} else if ( dr . roughness > r2 && dr . bandRatio < b1 ) {
html += ` <li title="SECONDARY CRITERION PASSED (moderate). Roughness > ${ r2 } indicates moderate spectral scalloping. Band ratio < ${ b1 } confirms the high-frequency energy distribution is natural. Together they support the transcode hypothesis.">② roughness= ${ dr . roughness . toFixed ( 3 ) } > ${ r2 } <span class="pass">✓</span>, band_ratio= ${ dr . bandRatio . toFixed ( 3 ) } < ${ b1 } <span class="pass">✓</span></li> ` ;
secHit = true ;
} else if ( dr . roughness > r3 && dr . bandRatio < b2 ) {
html += ` <li title="SECONDARY CRITERION PASSED (weak). Mild roughness > ${ r3 } combined with a healthy band ratio < ${ b2 } provides marginal evidence of lossy encoding. May be a false positive on very processed audio.">② roughness= ${ dr . roughness . toFixed ( 3 ) } > ${ r3 } <span class="pass">✓</span>, band_ratio= ${ dr . bandRatio . toFixed ( 3 ) } < ${ b2 } <span class="pass">✓</span></li> ` ;
secHit = true ;
}
if ( secHit ) {
html += ` <li class="hint" title="When secondary criteria (roughness + band ratio) also trigger, the transcode detection is confirmed even if the transition bandwidth alone was ambiguous (e.g., at high cutoff ratios where steepness is naturally low).">→ Secondary criteria triggered: transcode confirmed</li> ` ;
} else if ( ! primaryHit ) {
html += ` <li title="SECONDARY CRITERION NOT MET. Roughness is low and band ratio is in the normal range. No spectral evidence of lossy quantization artifacts in the high frequencies.">② secondary: roughness= ${ dr . roughness . toFixed ( 3 ) } , band_ratio= ${ dr . bandRatio . toFixed ( 3 ) } <span class="fail">✗</span></li> ` ;
html += ` <li class="hint" title="Neither primary (transition bandwidth) nor secondary (roughness + band ratio) criteria detected any transcode signature. The file behaves like native lossless across all spectral checks.">→ No transcode criteria met: genuine lossless</li> ` ;
}
} else if ( ! primaryHit ) {
html += ` <li class="hint" title="The cutoff ratio is too low for secondary criteria to apply (≤ 0.80). Since the primary criterion also did not trigger, no transcode signature was detected. However, the low cutoff may warrant investigation — it could be a low-bitrate lossy file or a recording with naturally limited bandwidth.">→ No transcode criteria met: genuine lossless</li> ` ;
}
}
html += '</ul>' ;
el . innerHTML = html ;
} ,
renderChart ( result , detectResult , effectiveCutoff , sampleRate ) {
const canvas = document . getElementById ( 'spectrumChart' ) ;
const ctx = canvas . getContext ( '2d' ) ;
canvas . width = canvas . parentElement . clientWidth * window . devicePixelRatio ;
canvas . height = 360 * window . devicePixelRatio ;
canvas . style . width = canvas . parentElement . clientWidth + 'px' ;
canvas . style . height = '360px' ;
ctx . scale ( window . devicePixelRatio , window . devicePixelRatio ) ;
const W = canvas . width / window . devicePixelRatio ;
const H = 360 ;
const pad = { top : 20 , right : 30 , bottom : 40 , left : 55 } ;
const chartW = W - pad . left - pad . right ;
const chartH = H - pad . top - pad . bottom ;
const n = result . fftSize / 2 ;
const nyquist = sampleRate / 2.0 ;
const mag = new Float64Array ( n ) ;
let peak = 0 ;
for ( let i = 0 ; i < n ; i ++ ) {
mag [ i ] = Math . sqrt ( result . power [ i ] / result . count ) ;
if ( mag [ i ] > peak ) peak = mag [ i ] ;
}
if ( peak < 1e-12 ) peak = 1e-12 ;
for ( let i = 0 ; i < n ; i ++ )
mag [ i ] = 20.0 * Math . log10 ( mag [ i ] / peak ) ;
ctx . clearRect ( 0 , 0 , W , H ) ;
this . drawGrid ( ctx , pad , chartW , chartH , nyquist ) ;
const minDb = - 100 ;
const maxDb = 0 ;
function freqToX ( freq ) {
const minFreq = 20 ;
const maxFreq = nyquist ;
const norm = ( Math . log ( freq / minFreq ) / Math . log ( maxFreq / minFreq ) ) ;
return pad . left + norm * chartW ;
}
function dbToY ( db ) {
return pad . top + chartH - ( ( db - minDb ) / ( maxDb - minDb ) ) * chartH ;
}
ctx . beginPath ( ) ;
let started = false ;
for ( let i = 0 ; i < n ; i ++ ) {
const freq = i * sampleRate / result . fftSize ;
if ( freq < 20 ) continue ;
const x = freqToX ( freq ) ;
const y = dbToY ( Math . max ( minDb , mag [ i ] ) ) ;
if ( ! started ) { ctx . moveTo ( x , y ) ; started = true ; }
else ctx . lineTo ( x , y ) ;
}
ctx . lineTo ( freqToX ( nyquist ) , dbToY ( minDb ) ) ;
ctx . lineTo ( freqToX ( 20 ) , dbToY ( minDb ) ) ;
ctx . closePath ( ) ;
const grad = ctx . createLinearGradient ( 0 , pad . top , 0 , pad . top + chartH ) ;
grad . addColorStop ( 0 , 'rgba(0, 188, 212, 0.35)' ) ;
grad . addColorStop ( 0.5 , 'rgba(0, 188, 212, 0.15)' ) ;
grad . addColorStop ( 1 , 'rgba(0, 188, 212, 0.02)' ) ;
ctx . fillStyle = grad ;
ctx . fill ( ) ;
ctx . beginPath ( ) ;
started = false ;
for ( let i = 0 ; i < n ; i ++ ) {
const freq = i * sampleRate / result . fftSize ;
if ( freq < 20 ) continue ;
const x = freqToX ( freq ) ;
const y = dbToY ( Math . max ( minDb , mag [ i ] ) ) ;
if ( ! started ) { ctx . moveTo ( x , y ) ; started = true ; }
else ctx . lineTo ( x , y ) ;
}
ctx . strokeStyle = '#00bcd4' ;
ctx . lineWidth = 1.5 ;
ctx . stroke ( ) ;
if ( effectiveCutoff > 0 ) {
const cx = freqToX ( effectiveCutoff ) ;
ctx . beginPath ( ) ;
ctx . moveTo ( cx , pad . top ) ;
ctx . lineTo ( cx , pad . top + chartH ) ;
ctx . strokeStyle = '#ffc107' ;
ctx . lineWidth = 2 ;
ctx . setLineDash ( [ 5 , 4 ] ) ;
ctx . stroke ( ) ;
ctx . setLineDash ( [ ] ) ;
const label = effectiveCutoff >= 1000
? ` ${ ( effectiveCutoff / 1000 ) . toFixed ( 1 ) } kHz `
: ` ${ effectiveCutoff . toFixed ( 0 ) } Hz ` ;
ctx . fillStyle = '#ffc107' ;
ctx . font = 'bold 12px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'center' ;
ctx . fillText ( label , cx , pad . top - 5 ) ;
}
const contourDb = detectResult . noiseDb ;
if ( contourDb > minDb ) {
const ny = dbToY ( contourDb ) ;
ctx . beginPath ( ) ;
ctx . moveTo ( pad . left , ny ) ;
ctx . lineTo ( pad . left + chartW , ny ) ;
ctx . strokeStyle = 'rgba(255, 82, 82, 0.5)' ;
ctx . lineWidth = 1 ;
ctx . setLineDash ( [ 3 , 3 ] ) ;
ctx . stroke ( ) ;
ctx . setLineDash ( [ ] ) ;
ctx . fillStyle = 'rgba(255, 82, 82, 0.7)' ;
ctx . font = '11px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'left' ;
ctx . fillText ( ` Noise floor ${ contourDb . toFixed ( 1 ) } dB ` , pad . left + 5 , ny - 4 ) ;
}
} ,
renderDataTable ( result , detectResult , peakDb , isLossy , upscaled , confidence , effectiveCutoff , sampleRate ) {
const nyquist = sampleRate / 2.0 ;
const tbody = document . querySelector ( '#dataTable tbody' ) ;
if ( ! tbody ) return ;
tbody . innerHTML = '' ;
function addRow ( label , value ) {
const tr = document . createElement ( 'tr' ) ;
tr . innerHTML = ` <td> ${ label } </td><td> ${ value } </td> ` ;
tbody . appendChild ( tr ) ;
}
addRow ( 'Sample Rate' , ` ${ sampleRate } Hz ` ) ;
addRow ( 'Nyquist Frequency' , ` ${ nyquist . toFixed ( 0 ) } Hz ` ) ;
addRow ( 'FFT Size' , ` ${ result . fftSize } ` ) ;
addRow ( 'Windows Analyzed' , ` ${ result . count } ` ) ;
addRow ( 'Peak Magnitude' , ` ${ peakDb . toFixed ( 1 ) } dBFS ` ) ;
addRow ( 'Cutoff Frequency' , ` ${ detectResult . cutoff . toFixed ( 0 ) } Hz ( ${ ( 100 * detectResult . cutoff / nyquist ) . toFixed ( 1 ) } % Nyquist) ` ) ;
addRow ( 'Extended Cutoff' , ` ${ detectResult . extendedCutoff . toFixed ( 0 ) } Hz ( ${ ( 100 * detectResult . extendedCutoff / nyquist ) . toFixed ( 1 ) } % Nyquist) ` ) ;
addRow ( 'Effective Cutoff' , ` ${ effectiveCutoff . toFixed ( 0 ) } Hz ( ${ ( 100 * effectiveCutoff / nyquist ) . toFixed ( 1 ) } % Nyquist) ` ) ;
addRow ( 'Cutoff Ratio' , ` ${ detectResult . cutoffRatio . toFixed ( 4 ) } ` ) ;
addRow ( 'Transition Bandwidth (Steepness)' , ` ${ detectResult . steepness . toFixed ( 0 ) } Hz ` ) ;
addRow ( 'Max Bandwidth Threshold' , detectResult . maxBw ? ` ${ detectResult . maxBw . toFixed ( 0 ) } Hz ` : 'N/A (bypass)' ) ;
addRow ( 'Noise Floor' , ` ${ detectResult . noiseDb . toFixed ( 1 ) } dB ` ) ;
addRow ( 'Roughness (CoV)' , ` ${ detectResult . roughness . toFixed ( 4 ) } ` ) ;
addRow ( 'Band Ratio (16-20kHz)/(12-16kHz)' , ` ${ detectResult . bandRatio . toFixed ( 4 ) } ` ) ;
addRow ( 'Detection Score' , ` ${ detectResult . score } ` ) ;
addRow ( 'Confidence' , ` ${ confidence . toFixed ( 1 ) } % ` ) ;
addRow ( 'Verdict' , isLossy ? ( upscaled ? 'UPSCALED' : 'NATIVE' ) : ( detectResult . score ? 'TRANSCODE' : 'GENUINE' ) ) ;
} ,
drawGrid ( ctx , pad , chartW , chartH , nyquist ) {
ctx . fillStyle = '#1a1a2e' ;
ctx . fillRect ( 0 , 0 , ctx . canvas . width / window . devicePixelRatio , 360 ) ;
const tickFreqs = [ 20 , 50 , 100 , 200 , 500 , 1000 , 2000 , 5000 , 10000 , 15000 , 20000 ] ;
const tickLabels = [ '20' , '50' , '100' , '200' , '500' , '1k' , '2k' , '5k' , '10k' , '15k' , '20k' ] ;
const minFreq = 20 ;
function freqToX ( freq ) {
const maxFreq = nyquist ;
const norm = ( Math . log ( freq / minFreq ) / Math . log ( maxFreq / minFreq ) ) ;
return pad . left + norm * chartW ;
}
ctx . strokeStyle = 'rgba(255,255,255,0.06)' ;
ctx . lineWidth = 1 ;
for ( let db = - 100 ; db <= 0 ; db += 10 ) {
const y = pad . top + chartH - ( ( db - ( - 100 ) ) / ( 0 - ( - 100 ) ) ) * chartH ;
ctx . beginPath ( ) ;
ctx . moveTo ( pad . left , y ) ;
ctx . lineTo ( pad . left + chartW , y ) ;
ctx . stroke ( ) ;
}
for ( const f of tickFreqs ) {
if ( f >= nyquist ) break ;
const x = freqToX ( f ) ;
ctx . beginPath ( ) ;
ctx . moveTo ( x , pad . top ) ;
ctx . lineTo ( x , pad . top + chartH ) ;
ctx . stroke ( ) ;
}
ctx . fillStyle = 'rgba(255,255,255,0.5)' ;
ctx . font = '10px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'center' ;
for ( let i = 0 ; i < tickFreqs . length ; i ++ ) {
if ( tickFreqs [ i ] >= nyquist ) break ;
const x = freqToX ( tickFreqs [ i ] ) ;
ctx . fillText ( tickLabels [ i ] , x , pad . top + chartH + 16 ) ;
}
ctx . fillStyle = 'rgba(255,255,255,0.3)' ;
ctx . font = '10px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'right' ;
for ( let db = - 100 ; db <= 0 ; db += 20 ) {
const y = pad . top + chartH - ( ( db - ( - 100 ) ) / ( 0 - ( - 100 ) ) ) * chartH ;
ctx . fillText ( ` ${ db } dB ` , pad . left - 6 , y + 4 ) ;
}
ctx . fillStyle = 'rgba(255,255,255,0.4)' ;
ctx . font = '11px "SF Mono", "Fira Code", monospace' ;
ctx . textAlign = 'center' ;
ctx . fillText ( 'Frequency' , pad . left + chartW / 2 , pad . top + chartH + 34 ) ;
ctx . save ( ) ;
ctx . translate ( 14 , pad . top + chartH / 2 ) ;
ctx . rotate ( - Math . PI / 2 ) ;
ctx . fillText ( 'Magnitude' , 0 , 0 ) ;
ctx . restore ( ) ;
} ,
showLoading ( msg ) {
const ov = document . getElementById ( 'loadingOverlay' ) ;
if ( ov ) ov . style . display = 'flex' ;
document . getElementById ( 'loadingMsg' ) . textContent = msg ;
document . getElementById ( 'errorMsg' ) . style . display = 'none' ;
} ,
showError ( msg ) {
const ov = document . getElementById ( 'loadingOverlay' ) ;
if ( ov ) ov . style . display = 'none' ;
const el = document . getElementById ( 'errorMsg' ) ;
el . textContent = msg ;
el . style . display = 'block' ;
} ,
formatSize ( bytes ) {
if ( bytes < 1024 ) return ` ${ bytes } B ` ;
if ( bytes < 1024 * 1024 ) return ` ${ ( bytes / 1024 ) . toFixed ( 1 ) } KB ` ;
return ` ${ ( bytes / ( 1024 * 1024 ) ) . toFixed ( 1 ) } MB ` ;
} ,
sleep ( ms ) { return new Promise ( r => setTimeout ( r , ms ) ) ; }
} ;
document . addEventListener ( 'DOMContentLoaded' , ( ) => App . init ( ) ) ;