Guest User

Untitled

a guest
Aug 5th, 2022
30
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 9.64 KB | None | 0 0
  1. #! /bin/env python3
  2. # -*- coding: utf-8 -*-
  3.  
  4. #
  5. #   Simulate open loop static crossover distortion of EF output stage
  6. #   Output distortion into WAV file so it can be listened to.
  7. #
  8. #
  9. #   Caveat: this uses a simple exponential BJT plus emitter resistor model:
  10. #
  11. #       No drivers, no beta droop, etc
  12. #       No switching artifacts
  13. #       No charge storage
  14. #       No power supply
  15. #       No feedback
  16. #
  17.  
  18. import time, subprocess, sys, pprint, hashlib
  19. from multiprocessing import Pool
  20. import numpy as np
  21. import scipy.interpolate
  22. import matplotlib.pyplot as plt
  23. from matplotlib.ticker import FuncFormatter
  24. from path import Path
  25.  
  26. TMPDIR = Path("/mnt/ssd/temp")
  27. CHUNK_SIZE  = 131072    # in samples
  28. N_CORES = 6             # number of processes (not counting sox)
  29.  
  30. #
  31. #   Output parameters
  32. #
  33. PEAK_VOLTAGE = 12               # Volume control setting for digital full scale
  34. LOUDSPEAKER_IMPEDANCE = 4.0     # Ohms resistive load
  35.  
  36. class Const(object):
  37.     # physical constants
  38.     q = 1.6021766208e-19  # electron charge
  39.     k = 1.38064852e-23    # boltzmann constant
  40.     T = 300               # temperature, kelvins
  41.    
  42.     Vt=k*T/q
  43.  
  44. def dB( x ):
  45.     return 10*np.log10( (x*x.conj()).real )
  46.  
  47. #   Solves (and precalculates) the BJT equations for the following circuit :
  48. #
  49. #         VCC
  50. #          |
  51. #   Vin--|<
  52. #          |
  53. #          R
  54. #          |
  55. #         GND
  56. #
  57. #   Parameter "R" is the emitter resistor.
  58. #   Ie = I(R) will span from 0 to parameter "imax", using "steps" values.
  59. #   Early effect etc is not modeled.
  60. #
  61. def generate_transistor_iv( R, imax, steps, IS=9.8145e-12 ):
  62.     Vt = Const.Vt
  63.     I = np.logspace( np.log10(imax*1e-9), np.log10(imax), steps, True, dtype=np.float64 )[1:]
  64.     Vin = R*I + Vt*np.log( I/IS )
  65.     return I, Vin
  66.  
  67. #
  68. #   models a class-AB common collector push pull BJT output stage
  69. #   Re1, IS1 : top NPN
  70. #   Re2, IS1 : bottom PNP
  71. #
  72. class OutputStage( object ):
  73.     def __init__( self, Re, Rs=0., IS1=5E-12, IS2=7e-12 ):
  74.         self.Re1 = Re
  75.         self.Re2 = Re
  76.         self.Rs  = Rs   # series resistor in the output
  77.         self.IS1 = IS1
  78.         self.IS2 = IS2
  79.  
  80.     #   Precalculate distortion tables for :
  81.     #   ibias : idle bias current
  82.     #   imax  : maximum current to compute
  83.     #   steps : number of simulation points
  84.     def set_bias( self, ibias, imax, steps ):
  85.         self.imax = imax
  86.         self.ibias = ibias
  87.        
  88.         # generate characteristics for each transistor and its associated resistor
  89.         I1, V1 = generate_transistor_iv( self.Re1, imax+ibias+1e-3, steps, self.IS1 )
  90.         I2, V2 = generate_transistor_iv( self.Re2, imax+ibias+1e-3, steps, self.IS2 )
  91.  
  92.         # compute bias voltage : find Vin for our common emitter transistor
  93.         # which gives the requested bias current. This gives each transistor's
  94.         # bias voltage. The voltage on the Vbe multiplier bias spreader would be the sum.
  95.         VBias1, = np.interp( [ibias], I1, V1 )
  96.         VBias2, = np.interp( [ibias], I2, V2 )
  97.  
  98.         # Add bias voltage to both "Vbe+V(Re)" vectors
  99.         # So when Vin=Vout, Iout=0 and I1=I2=ibias
  100.         V1 -= VBias1
  101.         V2  = -(V2 - VBias2)    # also flip the PNP's voltage
  102.        
  103.         # From Vd = Vout-Vin, calculate current in each transistor
  104.         # and resample it so we have a precalculated vector using Vd as axis.
  105.         Vd = np.unique( np.hstack(( V1, V2 )))  # sort and remove duplicates
  106.         I1 = np.interp( Vd, V1, I1 )
  107.         I2 = np.interp( Vd, V2[::-1], I2[::-1] )    # flip arrays, interp needs increasing order
  108.  
  109.         # output current into load
  110.         Iout = I1 - I2
  111.        
  112.         # snip off the invalid excess values
  113.         mi, ma = np.searchsorted( Iout, (-imax, imax) )
  114.         mi = max( mi-1, 0 ) # ensure [-imax, imax] interval is included inside returned values
  115.        
  116.         # Remove linear output impedance, keep only distortion
  117.         # If gm-doubling occurs, this will invert distortion polarity in the part of the
  118.         # waveform through the crossover when  output impedance is lower than Re. So it's
  119.         # a bit wonky.
  120.         # Vd -= Iout*self.Re1
  121.  
  122.         # Vd = (Vout-Vin) is the distortion added by the output stage, it depends on Iout
  123.         self._disto_Iout = Iout[mi:ma]
  124.         self._disto_Vd   = Vd[mi:ma]
  125.         self._disto_Iout_min = self._disto_Iout[0]
  126.         self._disto_Iout_max = self._disto_Iout[-1]
  127.        
  128.         return self._disto_Iout, self._disto_Vd
  129.  
  130.     #   calculate distortion due to output stage when Iout = "current"
  131.     def get_error_voltage( self, current ):
  132.         assert current.max() <= self._disto_Iout_max
  133.         assert current.min() >= self._disto_Iout_min
  134.         return np.interp( current, self._disto_Iout, self._disto_Vd )
  135.  
  136.     # Helper : Plot a wing diagram
  137.     # ibias should be an array of bias currents to plot
  138.     def plot_wing_diagram( self, ibias_list=[None], imax=None, steps=None ):
  139.         # plt.figure()
  140.         for ibias in ibias_list:
  141.             if ibias != None:
  142.                 self.set_bias( ibias, imax, steps )
  143.             i, v = self._disto_Iout, self._disto_Vd
  144.             label = "%d mA %.02fR"%(self.ibias*1000,self.Re1)
  145.             y = np.diff(v)/np.diff(i)
  146.             plt.plot( i[1:], y, label=label)
  147.             xpos = int(len(i)*0.6)
  148.             if self.Re1:
  149.                 xpos=0
  150.             plt.text( i[xpos], y[xpos], label )
  151.         plt.xlabel( "Iout (A)" )
  152.         plt.ylabel( "dV/dI = dynamic resistance" )
  153.         plt.grid( True )
  154.         # plt.legend()
  155.    
  156.  
  157. # Has to be a class for multiprocessing
  158. class DistortFile( object ):
  159.     def __init__( self, _fname, _ops_list ):
  160.         self.fname = Path( _fname )
  161.         self.ops_list = _ops_list
  162.  
  163.         # get file info
  164.         print( '\nFile "%s"' % self.fname )
  165.         p = subprocess.run([ "soxi", self.fname ], capture_output=True, encoding=sys.stdout.encoding )
  166.         attrs = {}
  167.         for line in p.stdout.split("\n"):
  168.             kv = line.split(":")
  169.             if len(kv)==2:
  170.                 attrs[kv[0].strip().lower()] = kv[1].strip()
  171.  
  172.         pprint.pprint( attrs )
  173.         self.srate_file  = int(attrs["sample rate"])
  174.         self.channels    = int(attrs["channels"])
  175.  
  176.         # get base sample rate
  177.         if not self.srate_file % 44100:
  178.             self.srate_base = 44100
  179.         elif not srate_file % 48000:
  180.             self.srate_base = 48000
  181.         else:
  182.             raise ValueError("Only 48k and 44.1k base rates supported.")
  183.  
  184.         # decide sample rates for oversampling
  185.         self.srate_high = max( self.srate_file, self.srate_base*64 )
  186.         print( "Oversampling %.02fk -> %.02fk" % ( self.srate_file/1000, self.srate_high/1000 ))
  187.  
  188.         # oversample file and store in temporary file
  189.         # to avoid doing it again if we want to change the distortion settings
  190.         self.tmpfile = TMPDIR/hashlib.sha256(self.fname.encode("utf-8")).hexdigest()+".raw"
  191.  
  192.         if not self.tmpfile.isfile():
  193.             cmd = [ "sox", self.fname, "--encoding", "float", "--bits", "32",
  194.                     "--type", "raw", "--endian", "little", "--no-dither",
  195.                     str(self.tmpfile), "rate", "-v", str(self.srate_high) ]        
  196.             print( cmd )
  197.             subprocess.run( cmd )
  198.         self.raw_size = self.tmpfile.size
  199.         print( "Oversampled data %.02f Gb" % (self.raw_size/1024**3) )
  200.  
  201.         # Parallel processing
  202.         with Pool( N_CORES ) as p:
  203.             p.map( self.proc, self.ops_list )
  204.  
  205.     def proc( self, ops ):
  206.         # Setup output file
  207.         outfile = TMPDIR/self.fname.basename()+(" %.03fR %.03fR %.1fmA.wav"%(ops.Re1,ops.Re2,ops.ibias) )
  208.         print( "Writing", outfile )
  209.  
  210.         # Use sox to downsample our high sample rate simulation back to original sample rate
  211.         cmd = [ "sox", "--encoding", "float", "--bits", "32", "--type", "raw", "--endian", "little", "--rate", str(self.srate_high), "--channels", str(self.channels), "-",
  212.                 "--encoding", "signed", "--bits", "24", str(outfile), "rate", "-v", str(self.srate_file) ]
  213.         p = subprocess.Popen( cmd, stdin=subprocess.PIPE )
  214.  
  215.         # mmap input file
  216.         data = np.memmap( self.tmpfile, dtype=np.float32, mode='r', offset=0, shape=(self.channels, self.raw_size//(4*self.channels)), order='F')
  217.  
  218.         # process it in chunks, can't load the whole file in memory
  219.         t = 0
  220.         for pos in range( 0, data.shape[1], CHUNK_SIZE ):
  221.             chunk = data[:, pos:(pos+CHUNK_SIZE) ] * (PEAK_VOLTAGE/LOUDSPEAKER_IMPEDANCE)   # get output current
  222.             disto = ops.get_error_voltage( chunk ) * (1.0/PEAK_VOLTAGE)     # get distortion and normalize it to input signal
  223.             p.stdin.write( disto.astype(np.float32).tobytes(order="F") )    # send to sox
  224.             tt = time.time()
  225.             if tt>t:
  226.                 sys.stdout.write( "%.1f%%\r" % (pos*100/data.shape[1]))
  227.                 t = tt+1.0
  228.  
  229. if __name__ == '__main__':
  230.  
  231.     plt.rc('font', size=12)
  232.     ops_list = []
  233.  
  234.     # Pairs of (Bias in amps, Re in ohms)
  235.     for bias,Re in [(0.02, 0.51), (0.05,0.47), (0.1, 0.22), (0.2, 0.1)]:
  236.  
  237.         # do it without emitter resistors
  238.         ops = OutputStage( Re=0, Rs=0 )
  239.         ops.set_bias( bias, 1.1*PEAK_VOLTAGE/LOUDSPEAKER_IMPEDANCE, 65536 )
  240.         ops_list.append( ops )
  241.  
  242.         # do it with emitter resistors
  243.         ops = OutputStage( Re=Re )
  244.         ops.set_bias( bias, 1.1*PEAK_VOLTAGE/LOUDSPEAKER_IMPEDANCE, 65536 )
  245.         ops_list.append( ops )
  246.  
  247.     # plot it before running it
  248.     for ops in ops_list:
  249.         ops.plot_wing_diagram()
  250.     plt.show()
  251.  
  252.     DistortFile(  "/mnt/disk/music/flac/albums/Rebecca Pidgeon/The Raven/05 Grandmother.flac", ops_list )
  253.  
Add Comment
Please, Sign In to add comment